mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Hosted components (#1229)
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a new "Hosted Components" app with its own app shell, routing, auth-aware UI, a handler route, and a welcome page showing the signed-in user. * **Chores** * Added dev tooling and configuration for the new app (build, lint, typecheck, Vite/TS, package manifest) and updated dev env API URL. * **Tests** * Excluded the new app from the test workspace. * **Bug Fixes** * Suppressed noisy console errors for a specific internal sentinel and clarified related error messaging. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com>
This commit is contained in:
parent
85ea5d25c8
commit
a64055cfca
@ -182,6 +182,14 @@
|
||||
img: "https://www.svgrepo.com/show/448400/docs.svg",
|
||||
importance: 2,
|
||||
},
|
||||
{
|
||||
name: "Hosted Components",
|
||||
portSuffix: "09",
|
||||
description: [
|
||||
"Src: ./apps/hosted-components",
|
||||
],
|
||||
importance: 2,
|
||||
},
|
||||
{
|
||||
name: "Inbucket",
|
||||
portSuffix: "05",
|
||||
|
||||
1
apps/hosted-components/.env.development
Normal file
1
apps/hosted-components/.env.development
Normal file
@ -0,0 +1 @@
|
||||
VITE_STACK_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02
|
||||
4
apps/hosted-components/.eslintrc.cjs
Normal file
4
apps/hosted-components/.eslintrc.cjs
Normal file
@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
extends: ["../../configs/eslint/defaults.js"],
|
||||
ignorePatterns: ["/*", "!/src"],
|
||||
};
|
||||
37
apps/hosted-components/package.json
Normal file
37
apps/hosted-components/package.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@stackframe/hosted-components",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09",
|
||||
"build": "vite build",
|
||||
"start": "node .output/server/index.mjs",
|
||||
"lint": "eslint --ext .ts,.tsx .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rimraf .output && rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stackframe/react": "workspace:*",
|
||||
"@stackframe/stack-shared": "workspace:*",
|
||||
"@tanstack/react-router": "^1.121.3",
|
||||
"@tanstack/react-start": "^1.121.3",
|
||||
"@tanstack/react-start-client": "^1.121.3",
|
||||
"@tanstack/react-start-server": "^1.121.3",
|
||||
"@tanstack/start-client-core": "^1.121.3",
|
||||
"@tanstack/start-server-core": "^1.121.3",
|
||||
"@tanstack/start-plugin-core": "^1.121.3",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.0",
|
||||
"@types/react": "19.2.1",
|
||||
"@types/react-dom": "19.2.1",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^7.0.0",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.23.0"
|
||||
}
|
||||
12
apps/hosted-components/src/client.tsx
Normal file
12
apps/hosted-components/src/client.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { StartClient } from '@tanstack/react-start/client';
|
||||
import { StrictMode, startTransition } from 'react';
|
||||
import { hydrateRoot } from 'react-dom/client';
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<StartClient />
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
86
apps/hosted-components/src/routeTree.gen.ts
Normal file
86
apps/hosted-components/src/routeTree.gen.ts
Normal file
@ -0,0 +1,86 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as HandlerSplatRouteImport } from './routes/handler/$'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const HandlerSplatRoute = HandlerSplatRouteImport.update({
|
||||
id: '/handler/$',
|
||||
path: '/handler/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/handler/$': typeof HandlerSplatRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/handler/$': typeof HandlerSplatRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/handler/$': typeof HandlerSplatRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/handler/$'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/handler/$'
|
||||
id: '__root__' | '/' | '/handler/$'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
HandlerSplatRoute: typeof HandlerSplatRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/handler/$': {
|
||||
id: '/handler/$'
|
||||
path: '/handler/$'
|
||||
fullPath: '/handler/$'
|
||||
preLoaderRoute: typeof HandlerSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
HandlerSplatRoute: HandlerSplatRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
12
apps/hosted-components/src/router.tsx
Normal file
12
apps/hosted-components/src/router.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { createRouter } from '@tanstack/react-router';
|
||||
import { routeTree } from './routeTree.gen';
|
||||
|
||||
export function getRouter() {
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
scrollRestoration: true,
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
137
apps/hosted-components/src/routes/__root.tsx
Normal file
137
apps/hosted-components/src/routes/__root.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { StackClientApp, StackProvider, StackTheme } from '@stackframe/react';
|
||||
import {
|
||||
HeadContent,
|
||||
Outlet,
|
||||
Scripts,
|
||||
createRootRoute,
|
||||
useNavigate
|
||||
} from '@tanstack/react-router';
|
||||
import type { ErrorInfo, ReactNode } from 'react';
|
||||
import { Component, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
|
||||
export function getProjectId(): string | null {
|
||||
// Extract from subdomain: <projectId>.built-with-stack-auth.com
|
||||
// Also works with <projectId>.localhost for local dev
|
||||
const hostname = window.location.hostname;
|
||||
const parts = hostname.split('.');
|
||||
if (parts.length >= 2) {
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function FullPageError({ title, message }: { title: string, message: string }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<div style={{ textAlign: 'center', maxWidth: 480, padding: 24 }}>
|
||||
<h1 style={{ fontSize: 24, marginBottom: 8 }}>{title}</h1>
|
||||
<p style={{ color: '#666' }}>{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
|
||||
constructor(props: { children: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Hosted components error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return <FullPageError title="Something went wrong" message={this.state.error.message} />;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ charSet: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
],
|
||||
}),
|
||||
shellComponent: RootDocument,
|
||||
component: RootComponent,
|
||||
});
|
||||
|
||||
function RootDocument({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html>
|
||||
<head>
|
||||
<HeadContent />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body style={{ fontFamily: "'Inter', sans-serif", margin: 0 }}>
|
||||
{children}
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
function RootComponent() {
|
||||
const [projectId, setProjectId] = useState<string | null | undefined>("internal");
|
||||
|
||||
useEffect(() => {
|
||||
setProjectId(getProjectId());
|
||||
}, []);
|
||||
|
||||
const isValidProjectId = projectId ? (projectId === "internal" || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(projectId)) : false;
|
||||
|
||||
const stackApp = useMemo(() => {
|
||||
if (!projectId || !isValidProjectId) return null;
|
||||
return new StackClientApp({
|
||||
projectId,
|
||||
tokenStore: "cookie",
|
||||
baseUrl: import.meta.env.VITE_STACK_API_URL || undefined,
|
||||
urls: {
|
||||
handler: "/handler",
|
||||
signIn: "/handler/sign-in",
|
||||
signUp: "/handler/sign-up",
|
||||
afterSignIn: "/",
|
||||
afterSignUp: "/",
|
||||
afterSignOut: "/handler/sign-in",
|
||||
},
|
||||
redirectMethod: { useNavigate: useNavigate as any }
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
if (projectId === undefined) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return <FullPageError title="Invalid URL" message={`Could not determine project ID from subdomain. Visit <projectId>.${window.location.host}.`} />;
|
||||
}
|
||||
|
||||
if (!isValidProjectId) {
|
||||
return <FullPageError title="Something went wrong" message={`Invalid project ID: ${projectId}. Project IDs must be UUIDs.`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<StackProvider app={stackApp!}>
|
||||
<StackTheme>
|
||||
<Outlet />
|
||||
</StackTheme>
|
||||
</StackProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
14
apps/hosted-components/src/routes/handler/$.tsx
Normal file
14
apps/hosted-components/src/routes/handler/$.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { StackHandler } from '@stackframe/react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export const Route = createFileRoute('/handler/$')({
|
||||
component: HandlerPage,
|
||||
});
|
||||
|
||||
function HandlerPage() {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
useEffect(() => setIsMounted(true), []);
|
||||
if (!isMounted) return null;
|
||||
return <StackHandler fullPage />;
|
||||
}
|
||||
31
apps/hosted-components/src/routes/index.tsx
Normal file
31
apps/hosted-components/src/routes/index.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { UserButton, useUser } from "@stackframe/react";
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: HandlerPage,
|
||||
pendingComponent: () => (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: "100vh" }}>
|
||||
<div style={{ width: 24, height: 24, border: "2px solid #e5e5e5", borderTop: "2px solid #333", borderRadius: "50%", animation: "spin 0.6s linear infinite" }} />
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
function HandlerPage() {
|
||||
const user = useUser({ or: "redirect" });
|
||||
const name = user.displayName || user.primaryEmail || "User";
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", minHeight: "100vh", fontFamily: "system-ui, sans-serif" }}>
|
||||
<div style={{ position: "absolute", top: "1rem", right: "1rem" }}>
|
||||
<UserButton />
|
||||
</div>
|
||||
<h1 style={{ fontSize: "1.5rem", fontWeight: 500, marginBottom: "0.5rem" }}>
|
||||
Welcome, {name}
|
||||
</h1>
|
||||
<p style={{ color: "#666", fontSize: "0.875rem" }}>
|
||||
You are signed in.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
apps/hosted-components/tsconfig.json
Normal file
19
apps/hosted-components/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"target": "ES2021",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2021"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
90
apps/hosted-components/vite.config.ts
Normal file
90
apps/hosted-components/vite.config.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||||
import viteReact from '@vitejs/plugin-react'
|
||||
import { defineConfig, type Plugin } from 'vite'
|
||||
import tsConfigPaths from 'vite-tsconfig-paths'
|
||||
|
||||
/**
|
||||
* Makes Vite watch specific packages inside node_modules for changes.
|
||||
* By default Vite/chokidar ignores all of node_modules. This plugin uses
|
||||
* the `config()` hook to inject negation patterns before chokidar is
|
||||
* initialized, which is the only reliable way to un-ignore specific packages.
|
||||
* See: https://github.com/vitejs/vite/issues/8619
|
||||
*/
|
||||
function watchNodeModules(modules: string[]): Plugin {
|
||||
return {
|
||||
name: 'watch-node-modules',
|
||||
config() {
|
||||
return {
|
||||
server: {
|
||||
watch: {
|
||||
ignored: modules.map((m) => `!**/node_modules/${m}/**`),
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for workspace package dist directories to exist before letting
|
||||
* Vite resolve them. Fixes the race condition where `pnpm dev` starts
|
||||
* hosted-components before dependency packages have finished their
|
||||
* initial build (eg. after `rimraf dist` in their dev script).
|
||||
*/
|
||||
function waitForWorkspacePackages(packages: string[]): Plugin {
|
||||
const packageDistEntries = packages.map((pkg) => ({
|
||||
name: pkg,
|
||||
entry: path.resolve(__dirname, 'node_modules', pkg, 'dist', 'esm', 'index.js'),
|
||||
}))
|
||||
|
||||
async function waitForFile(filePath: string, timeoutMs = 60_000): Promise<void> {
|
||||
if (fs.existsSync(filePath)) return
|
||||
const start = Date.now()
|
||||
return new Promise((resolve, reject) => {
|
||||
const interval = setInterval(() => {
|
||||
if (fs.existsSync(filePath)) {
|
||||
clearInterval(interval)
|
||||
resolve()
|
||||
} else if (Date.now() - start > timeoutMs) {
|
||||
clearInterval(interval)
|
||||
reject(new Error(`Timed out waiting for ${filePath} to exist`))
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'wait-for-workspace-packages',
|
||||
enforce: 'pre',
|
||||
async buildStart() {
|
||||
const missing = packageDistEntries.filter((p) => !fs.existsSync(p.entry))
|
||||
if (missing.length > 0) {
|
||||
console.log(`Waiting for workspace packages to build: ${missing.map((p) => p.name).join(', ')}`)
|
||||
await Promise.all(missing.map((p) => waitForFile(p.entry)))
|
||||
console.log('All workspace packages are ready.')
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: Number((process.env.NEXT_PUBLIC_STACK_PORT_PREFIX || "81") + "09"),
|
||||
},
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom'],
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['@stackframe/react', '@stackframe/stack-shared'],
|
||||
},
|
||||
plugins: [
|
||||
waitForWorkspacePackages(['@stackframe/react', '@stackframe/stack-shared']),
|
||||
watchNodeModules(['@stackframe/react', '@stackframe/stack-shared']),
|
||||
tsConfigPaths(),
|
||||
tanstackStart(),
|
||||
// react's vite plugin must come after start's vite plugin
|
||||
viteReact(),
|
||||
],
|
||||
})
|
||||
29
packages/stack-shared/src/utils/monkey-patch.tsx
Normal file
29
packages/stack-shared/src/utils/monkey-patch.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { createGlobal } from "./globals";
|
||||
|
||||
export const NO_SUSPENSE_BOUNDARY_ERROR_SENTINEL = "__stack-no-suspense-boundary-error__";
|
||||
|
||||
export function isNoSuspenseBoundaryError(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === "object"
|
||||
&& value !== null
|
||||
&& (value as Record<string, unknown>).__noSuspenseBoundarySentinel === NO_SUSPENSE_BOUNDARY_ERROR_SENTINEL
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureMonkeyPatch() {
|
||||
createGlobal("__console-error-monkey-patch__", () => {
|
||||
const originalConsoleError = console.error;
|
||||
console.error = function (...args: unknown[]) {
|
||||
// React's default error handlers will log all errors to the console, even those that we intentionally use to suppress SSR.
|
||||
// Next.js among others override the default error handler and will not log SSR errors to the console.
|
||||
// However, vanilla React and other frameworks like TanStack Start use the default error handler.
|
||||
// Hence, we suppress the error here if it is a NoSuspenseBoundaryError.
|
||||
// It's very cursed, but it's really our best option. Talk to @konsti if you want to know more.
|
||||
if (args.length === 1 && isNoSuspenseBoundaryError(args[0])) {
|
||||
return;
|
||||
}
|
||||
return originalConsoleError.apply(this, args);
|
||||
};
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@ -2,6 +2,7 @@ import React, { SetStateAction } from "react";
|
||||
import { isBrowserLike } from "./env";
|
||||
import { neverResolve, runAsynchronously } from "./promises";
|
||||
import { AsyncResult } from "./results";
|
||||
import { ensureMonkeyPatch, NO_SUSPENSE_BOUNDARY_ERROR_SENTINEL } from "./monkey-patch";
|
||||
import { deindent } from "./strings";
|
||||
|
||||
export function componentWrapper<
|
||||
@ -233,14 +234,17 @@ export function shouldRethrowRenderingError(error: unknown): boolean {
|
||||
export class NoSuspenseBoundaryError extends Error {
|
||||
digest: string;
|
||||
reason: string;
|
||||
__noSuspenseBoundarySentinel = NO_SUSPENSE_BOUNDARY_ERROR_SENTINEL;
|
||||
|
||||
constructor(options: { caller?: string }) {
|
||||
ensureMonkeyPatch();
|
||||
|
||||
super(deindent`
|
||||
Suspense boundary not found! Read the error message below carefully on how to fix it.
|
||||
Suspense boundary not found! Read the error message below carefully (or paste it into your AI agent).
|
||||
|
||||
${options.caller ?? "This code path"} attempted to display a loading indicator, but didn't find a Suspense boundary above it. Please read the error message below carefully.
|
||||
|
||||
The fix depends on which of the 4 scenarios caused it:
|
||||
There are several potential causes:
|
||||
|
||||
1. [Next.js] You are missing a loading.tsx file in your app directory. Fix it by adding a loading.tsx file in your app directory.
|
||||
|
||||
@ -260,6 +264,8 @@ export class NoSuspenseBoundaryError extends Error {
|
||||
|
||||
4. You caught this error with try-catch or a custom error boundary. Fix this by rethrowing the error or not catching it in the first place.
|
||||
|
||||
5. Your version of Stack Auth is too old. Upgrade to the latest version to see if that fixes the issue.
|
||||
|
||||
See: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
|
||||
|
||||
More information on SSR and Suspense boundaries: https://react.dev/reference/react/Suspense#providing-a-fallback-for-server-errors-and-client-only-content
|
||||
|
||||
840
pnpm-lock.yaml
840
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -3,6 +3,7 @@ import { defineWorkspace } from 'vitest/config';
|
||||
export default defineWorkspace([
|
||||
'packages/*',
|
||||
'apps/*',
|
||||
'!apps/hosted-components',
|
||||
'docs',
|
||||
'examples/*',
|
||||
]);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user