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",
|
img: "https://www.svgrepo.com/show/448400/docs.svg",
|
||||||
importance: 2,
|
importance: 2,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Hosted Components",
|
||||||
|
portSuffix: "09",
|
||||||
|
description: [
|
||||||
|
"Src: ./apps/hosted-components",
|
||||||
|
],
|
||||||
|
importance: 2,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Inbucket",
|
name: "Inbucket",
|
||||||
portSuffix: "05",
|
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 { isBrowserLike } from "./env";
|
||||||
import { neverResolve, runAsynchronously } from "./promises";
|
import { neverResolve, runAsynchronously } from "./promises";
|
||||||
import { AsyncResult } from "./results";
|
import { AsyncResult } from "./results";
|
||||||
|
import { ensureMonkeyPatch, NO_SUSPENSE_BOUNDARY_ERROR_SENTINEL } from "./monkey-patch";
|
||||||
import { deindent } from "./strings";
|
import { deindent } from "./strings";
|
||||||
|
|
||||||
export function componentWrapper<
|
export function componentWrapper<
|
||||||
@ -233,14 +234,17 @@ export function shouldRethrowRenderingError(error: unknown): boolean {
|
|||||||
export class NoSuspenseBoundaryError extends Error {
|
export class NoSuspenseBoundaryError extends Error {
|
||||||
digest: string;
|
digest: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
__noSuspenseBoundarySentinel = NO_SUSPENSE_BOUNDARY_ERROR_SENTINEL;
|
||||||
|
|
||||||
constructor(options: { caller?: string }) {
|
constructor(options: { caller?: string }) {
|
||||||
|
ensureMonkeyPatch();
|
||||||
|
|
||||||
super(deindent`
|
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.
|
${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.
|
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.
|
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
|
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
|
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([
|
export default defineWorkspace([
|
||||||
'packages/*',
|
'packages/*',
|
||||||
'apps/*',
|
'apps/*',
|
||||||
|
'!apps/hosted-components',
|
||||||
'docs',
|
'docs',
|
||||||
'examples/*',
|
'examples/*',
|
||||||
]);
|
]);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user