From 8158c379a7fe4f390ed28f8e1973399fc58329e7 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:56:32 -0400 Subject: [PATCH 1/7] feat(blog): add route-specific pages for prefetched content --- docs/content/docs/plugins/blog.mdx | 46 +++++-- e2e/tests/blog-loading.ssg.spec.ts | 102 +++++++++++++++ packages/cli/package.json | 2 +- .../templates/nextjs/ssg-blog-list.tsx.hbs | 7 +- .../templates/nextjs/ssg-blog-post.tsx.hbs | 7 +- packages/stack/build.config.ts | 3 + packages/stack/knip.json | 3 + packages/stack/package.json | 43 ++++++- packages/stack/registry/btst-blog.json | 24 +++- .../blog/__tests__/page-loading.test.tsx | 106 ++++++++++++++++ .../client/components/pages/home-page.tsx | 74 +++++------ .../client/components/pages/post-page.tsx | 116 ++++++++++-------- .../blog/client/components/pages/tag-page.tsx | 64 +++++----- .../src/plugins/blog/client/pages/post.tsx | 7 ++ .../src/plugins/blog/client/pages/posts.tsx | 7 ++ .../src/plugins/blog/client/pages/tag.tsx | 7 ++ 16 files changed, 474 insertions(+), 144 deletions(-) create mode 100644 e2e/tests/blog-loading.ssg.spec.ts create mode 100644 packages/stack/src/plugins/blog/__tests__/page-loading.test.tsx create mode 100644 packages/stack/src/plugins/blog/client/pages/post.tsx create mode 100644 packages/stack/src/plugins/blog/client/pages/posts.tsx create mode 100644 packages/stack/src/plugins/blog/client/pages/tag.tsx diff --git a/docs/content/docs/plugins/blog.mdx b/docs/content/docs/plugins/blog.mdx index 128ce9c5..71dcf7c5 100644 --- a/docs/content/docs/plugins/blog.mdx +++ b/docs/content/docs/plugins/blog.mdx @@ -710,6 +710,34 @@ dehydrated protected data into a public page. | `"newPost"` | — | *(nothing)* | | `"editPost"` | `{ slug: string }` | Post to edit | +### Pages in native framework routes + +Data prefetching fills the query cache; it does not load a lazy page's JavaScript. +For separate framework route files, import the complete guarded page from its +own entry point: + +| Import path | Component | Props | +|---|---|---| +| `@btst/stack/plugins/blog/client/pages/posts` | `PostListPage` | `published?: boolean` (defaults to `true`) | +| `@btst/stack/plugins/blog/client/pages/post` | `PostPage` | `slug: string` | +| `@btst/stack/plugins/blog/client/pages/tag` | `TagPage` | `tagSlug: string` | + +These entries include the selected page's content directly and retain the same +permission checks, error boundaries, loading states for missing data, and route +hooks as the built-in routes. They still require `StackProvider` and a hydrated +`QueryClientProvider`. Prefetch the corresponding route's data before rendering. + +Import each entry in its own route file, not in a shared provider or client-stack +configuration: the framework can then load just that page and its shared +dependencies. BTST's generic route registry and the existing `client/components` +exports continue to lazy-load page content. Direct page imports render the +built-in UI; they do not apply `pageComponents` replacements configured on the +registry. Use your replacement component in the route file when applicable. + +This avoids the additional lazy page suspension when hydrating prefetched content. +Framework streaming, unresolved authorization, and missing data can still show a +loading boundary; secondary sections such as recent posts load independently. + ### Next.js example ```tsx title="app/pages/blog/page.tsx" @@ -718,6 +746,7 @@ import { getOrCreateQueryClient } from "@/lib/query-client" import { getStackClient } from "@/lib/stack-client" import { myStack } from "@/lib/stack" import { metaElementsToObject, normalizePath } from "@btst/stack/client" +import { PostListPage } from "@btst/stack/plugins/blog/client/pages/posts" import type { Metadata } from "next" // Opt into SSG — Next.js generates this page at build time @@ -738,14 +767,11 @@ export async function generateMetadata(): Promise { export default async function BlogListPage() { const queryClient = getOrCreateQueryClient() - const stackClient = getStackClient(queryClient) - const route = stackClient.router.getRoute(normalizePath(["blog"])) - if (!route) return null // Reads directly from DB — works at build time, no HTTP server required await myStack.raw.blog.prefetchForRoute("posts", queryClient) return ( - + ) } @@ -754,20 +780,20 @@ export default async function BlogListPage() { For individual post pages, also generate the static params list: ```tsx title="app/pages/blog/[slug]/page.tsx" +import { PostPage } from "@btst/stack/plugins/blog/client/pages/post" + export async function generateStaticParams() { const { items } = await myStack.trusted.blog.listPosts({ published: true, limit: 1000 }) return items.map((p) => ({ slug: p.slug })) } -export default async function BlogPostPage({ params }: { params: { slug: string } }) { +export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params const queryClient = getOrCreateQueryClient() - const stackClient = getStackClient(queryClient) - const route = stackClient.router.getRoute(normalizePath(["blog", params.slug])) - if (!route) return null - await myStack.raw.blog.prefetchForRoute("post", queryClient, { slug: params.slug }) + await myStack.raw.blog.prefetchForRoute("post", queryClient, { slug }) return ( - + ) } diff --git a/e2e/tests/blog-loading.ssg.spec.ts b/e2e/tests/blog-loading.ssg.spec.ts new file mode 100644 index 00000000..0dbbdb4e --- /dev/null +++ b/e2e/tests/blog-loading.ssg.spec.ts @@ -0,0 +1,102 @@ +import { expect, test } from "@playwright/test"; +import { mockAuthHeaders } from "./helpers/mock-auth"; + +// Fresh anonymous contexts exercise cold hydration of the generated native routes. +for (const route of ["list", "post"] as const) { + test(`prefetched ${route} stays visible with slow JavaScript and split page bundles`, async ({ + page, + request, + }) => { + const slug = `page-loading-${route}-${Date.now()}`; + const title = `Prefetched ${slug}`; + const created = await request.post("/api/data/posts", { + headers: mockAuthHeaders(), + data: { + title, + slug, + excerpt: "Page loading regression", + content: "Prefetched article body.", + published: true, + }, + }); + expect(created.ok(), await created.text()).toBeTruthy(); + const post = await created.json(); + const errors: string[] = []; + const scripts: Promise[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("response", (response) => { + if (response.request().resourceType() === "script") { + scripts.push(response.text()); + } + }); + await page.route("**/_next/**/*.js", async (request) => { + await new Promise((resolve) => setTimeout(resolve, 750)); + await request.continue(); + }); + await page.addInitScript(() => { + const state = { sawContent: false, skeletonAfterContent: false }; + Object.assign(window, { blogLoadingState: state }); + const visible = (selector: string) => + Array.from(document.querySelectorAll(selector)).some( + (element) => element.getBoundingClientRect().height > 0, + ); + function sample() { + if (visible('[data-testid="home-page"], [data-testid="post-page"]')) { + state.sawContent = true; + } + if ( + state.sawContent && + visible( + '[data-testid="posts-skeleton"], [data-testid="post-skeleton"]', + ) + ) { + state.skeletonAfterContent = true; + } + requestAnimationFrame(sample); + } + requestAnimationFrame(sample); + }); + try { + await page.goto( + route === "list" ? "/pages/ssg-blog" : `/pages/ssg-blog/${slug}`, + { + waitUntil: "networkidle", + }, + ); + await expect( + page.getByRole("heading", { + name: route === "list" ? "Blog Posts" : title, + exact: true, + }), + ).toBeVisible(); + expect( + await page.evaluate( + () => + ( + window as unknown as { + blogLoadingState: { + sawContent: boolean; + skeletonAfterContent: boolean; + }; + } + ).blogLoadingState, + ), + ).toEqual({ sawContent: true, skeletonAfterContent: false }); + expect(errors).toEqual([]); + const loadedCode = (await Promise.all(scripts)).join("\n"); + // These UI implementations belong to other lazy routes in the same stack. + for (const marker of [ + "milkdown-custom", + "cms-list-search", + "task-detail-bottom-slot", + ]) { + expect(loadedCode).not.toContain(marker); + } + } finally { + const deleted = await request.delete(`/api/data/posts/${post.id}`, { + headers: mockAuthHeaders(), + }); + expect(deleted.ok()).toBeTruthy(); + } + }); +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 8d8aa33c..8a500cce 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@btst/codegen", - "version": "0.2.1", + "version": "3.1.0-rc.1", "description": "BTST project scaffolding and CLI passthrough commands.", "repository": { "type": "git", diff --git a/packages/cli/src/templates/nextjs/ssg-blog-list.tsx.hbs b/packages/cli/src/templates/nextjs/ssg-blog-list.tsx.hbs index 16c4965e..a697ae70 100644 --- a/packages/cli/src/templates/nextjs/ssg-blog-list.tsx.hbs +++ b/packages/cli/src/templates/nextjs/ssg-blog-list.tsx.hbs @@ -6,11 +6,11 @@ * (lib/stack.ts) to purge the cache when posts change. */ import { dehydrate, HydrationBoundary } from "@tanstack/react-query" -import { notFound } from "next/navigation" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" import { getStackClient } from "{{alias}}lib/stack-client" import { myStack } from "{{alias}}lib/stack" import { metaElementsToObject, normalizePath } from "@btst/stack/client" +import { PostListPage } from "@btst/stack/plugins/blog/client/pages/posts" import type { Metadata } from "next" export async function generateStaticParams() { @@ -30,13 +30,10 @@ export async function generateMetadata(): Promise { export default async function SsgBlogListPage() { const queryClient = getOrCreateQueryClient() - const stackClient = getStackClient(queryClient) - const route = stackClient.router.getRoute(normalizePath(["blog"])) - if (!route) notFound() await myStack.raw.blog.prefetchForRoute("posts", queryClient) return ( - {route.PageComponent && } + ) } diff --git a/packages/cli/src/templates/nextjs/ssg-blog-post.tsx.hbs b/packages/cli/src/templates/nextjs/ssg-blog-post.tsx.hbs index f1d06376..5af3d462 100644 --- a/packages/cli/src/templates/nextjs/ssg-blog-post.tsx.hbs +++ b/packages/cli/src/templates/nextjs/ssg-blog-post.tsx.hbs @@ -6,11 +6,11 @@ * plugin hooks (lib/stack.ts) to purge the cache when a post changes. */ import { dehydrate, HydrationBoundary } from "@tanstack/react-query" -import { notFound } from "next/navigation" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" import { getStackClient } from "{{alias}}lib/stack-client" import { myStack } from "{{alias}}lib/stack" import { normalizePath, metaElementsToObject } from "@btst/stack/client" +import { PostPage } from "@btst/stack/plugins/blog/client/pages/post" import type { Metadata } from "next" export async function generateStaticParams() { @@ -41,13 +41,10 @@ export default async function SsgBlogPostPage({ }) { const { slug } = await params const queryClient = getOrCreateQueryClient() - const stackClient = getStackClient(queryClient) - const route = stackClient.router.getRoute(normalizePath(["blog", slug])) - if (!route) notFound() await myStack.raw.blog.prefetchForRoute("post", queryClient, { slug }) return ( - {route.PageComponent && } + ) } diff --git a/packages/stack/build.config.ts b/packages/stack/build.config.ts index 35383829..4ae0c46c 100644 --- a/packages/stack/build.config.ts +++ b/packages/stack/build.config.ts @@ -94,6 +94,9 @@ export default defineBuildConfig({ "./src/plugins/blog/api/index.ts", "./src/plugins/blog/client/index.ts", "./src/plugins/blog/client/components/index.tsx", + "./src/plugins/blog/client/pages/posts.tsx", + "./src/plugins/blog/client/pages/post.tsx", + "./src/plugins/blog/client/pages/tag.tsx", "./src/plugins/blog/client/hooks/index.tsx", "./src/plugins/blog/query-keys.ts", "./src/plugins/blog/permissions.ts", diff --git a/packages/stack/knip.json b/packages/stack/knip.json index ecee7c35..dcddb3be 100644 --- a/packages/stack/knip.json +++ b/packages/stack/knip.json @@ -20,6 +20,9 @@ "src/plugins/blog/api/index.ts", "src/plugins/blog/client/index.ts", "src/plugins/blog/client/components/index.tsx", + "src/plugins/blog/client/pages/posts.tsx", + "src/plugins/blog/client/pages/post.tsx", + "src/plugins/blog/client/pages/tag.tsx", "src/plugins/blog/client/hooks/index.tsx", "src/plugins/blog/query-keys.ts", "src/plugins/blog/permissions.ts", diff --git a/packages/stack/package.json b/packages/stack/package.json index d4277e6c..4008d690 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,6 +1,6 @@ { "name": "@btst/stack", - "version": "3.0.2", + "version": "3.1.0-rc.1", "description": "A composable, plugin-based library for building full-stack applications.", "repository": { "type": "git", @@ -799,7 +799,37 @@ }, "./dist/*": "./dist/*", "./ui/css": "./dist/ui/components.css", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./plugins/blog/client/pages/posts": { + "import": { + "types": "./dist/plugins/blog/client/pages/posts.d.ts", + "default": "./dist/plugins/blog/client/pages/posts.mjs" + }, + "require": { + "types": "./dist/plugins/blog/client/pages/posts.d.cts", + "default": "./dist/plugins/blog/client/pages/posts.cjs" + } + }, + "./plugins/blog/client/pages/post": { + "import": { + "types": "./dist/plugins/blog/client/pages/post.d.ts", + "default": "./dist/plugins/blog/client/pages/post.mjs" + }, + "require": { + "types": "./dist/plugins/blog/client/pages/post.d.cts", + "default": "./dist/plugins/blog/client/pages/post.cjs" + } + }, + "./plugins/blog/client/pages/tag": { + "import": { + "types": "./dist/plugins/blog/client/pages/tag.d.ts", + "default": "./dist/plugins/blog/client/pages/tag.mjs" + }, + "require": { + "types": "./dist/plugins/blog/client/pages/tag.d.cts", + "default": "./dist/plugins/blog/client/pages/tag.cjs" + } + } }, "typesVersions": { "*": { @@ -1024,6 +1054,15 @@ ], "components/empty": [ "./dist/components/empty/index.d.ts" + ], + "plugins/blog/client/pages/posts": [ + "./dist/plugins/blog/client/pages/posts.d.ts" + ], + "plugins/blog/client/pages/post": [ + "./dist/plugins/blog/client/pages/post.d.ts" + ], + "plugins/blog/client/pages/tag": [ + "./dist/plugins/blog/client/pages/tag.d.ts" ] } }, diff --git a/packages/stack/registry/btst-blog.json b/packages/stack/registry/btst-blog.json index 1454e2a3..42d4ac9b 100644 --- a/packages/stack/registry/btst-blog.json +++ b/packages/stack/registry/btst-blog.json @@ -178,7 +178,7 @@ { "path": "btst/blog/client/components/pages/home-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_PLUGIN_ID } from \"../../constants\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { PostsLoading } from \"../loading\";\nimport { NotFoundPage } from \"./404-page\";\nimport { blogPermissions } from \"../../../permissions\";\n\n// Lazy load the internal component with actual page content\nconst HomePage = lazy(() =>\n\timport(\"./home-page.internal\").then((m) => ({ default: m.HomePage })),\n);\n\n// Exported wrapped component with error and loading boundaries\nexport function HomePageComponent({\n\tpublished = true,\n}: {\n\tpublished?: boolean;\n}) {\n\tconst { onRouteError } =\n\t\tusePluginOverrides(BLOG_PLUGIN_ID);\n\treturn (\n\t\t {\n\t\t\t\tif (onRouteError) {\n\t\t\t\t\tonRouteError(\"posts\", error, {\n\t\t\t\t\t\tpath: published ? \"/blog\" : \"/blog/drafts\",\n\t\t\t\t\t\tisSSR: typeof window === \"undefined\",\n\t\t\t\t\t\tpublished,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}}\n\t\t/>\n\t);\n}\n", + "content": "\"use client\";\n\nimport { lazy, type ComponentType } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_PLUGIN_ID } from \"../../constants\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { PostsLoading } from \"../loading\";\nimport { NotFoundPage } from \"./404-page\";\nimport { blogPermissions } from \"../../../permissions\";\n\n// Share the guarded page between the lazy registry and native route entry.\nexport function createHomePage(\n\tHomePage: ComponentType<{ published: boolean }>,\n) {\n\t// Exported wrapped component with error and loading boundaries\n\tfunction HomePageComponent({ published = true }: { published?: boolean }) {\n\t\tconst { onRouteError } =\n\t\t\tusePluginOverrides(BLOG_PLUGIN_ID);\n\t\treturn (\n\t\t\t {\n\t\t\t\t\tif (onRouteError) {\n\t\t\t\t\t\tonRouteError(\"posts\", error, {\n\t\t\t\t\t\t\tpath: published ? \"/blog\" : \"/blog/drafts\",\n\t\t\t\t\t\t\tisSSR: typeof window === \"undefined\",\n\t\t\t\t\t\t\tpublished,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t/>\n\t\t);\n\t}\n\n\treturn HomePageComponent;\n}\n\nexport const HomePageComponent = createHomePage(\n\tlazy(() =>\n\t\timport(\"./home-page.internal\").then((m) => ({ default: m.HomePage })),\n\t),\n);\n", "target": "src/components/btst/blog/client/components/pages/home-page.tsx" }, { @@ -202,7 +202,7 @@ { "path": "btst/blog/client/components/pages/post-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_PLUGIN_ID } from \"../../constants\";\nimport {\n\tComposedRoute,\n\tPermissionRouteAccess,\n} from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { PostLoading } from \"../loading\";\nimport { NotFoundPage } from \"./404-page\";\nimport { useSuspensePost } from \"@btst/stack/plugins/blog/client/hooks\";\nimport { blogPermissions } from \"../../../permissions\";\n\n// Lazy load the internal component with actual page content\nconst PostPageContent = lazy(() =>\n\timport(\"./post-page.internal\").then((m) => ({ default: m.PostPage })),\n);\n\nfunction AuthorizedPostPage({ slug }: { slug: string }) {\n\tconst { post } = useSuspensePost(slug);\n\tconst permission = post\n\t\t? blogPermissions.post.read({\n\t\t\t\tscope: \"post\",\n\t\t\t\tslug: post.slug,\n\t\t\t\texists: true,\n\t\t\t\tid: post.id,\n\t\t\t\t...(post.authorId ? { authorId: post.authorId } : {}),\n\t\t\t\tpublished: post.published,\n\t\t\t})\n\t\t: blogPermissions.post.read({\n\t\t\t\tscope: \"post\",\n\t\t\t\tslug,\n\t\t\t\texists: false,\n\t\t\t\tpublished: false,\n\t\t\t});\n\treturn (\n\t\t\n\t\t\t\n\t\t\n\t);\n}\n\n// Exported wrapped component with error and loading boundaries\nexport function PostPageComponent({ slug }: { slug: string }) {\n\tconst { onRouteError } =\n\t\tusePluginOverrides(BLOG_PLUGIN_ID);\n\treturn (\n\t\t {\n\t\t\t\tif (onRouteError) {\n\t\t\t\t\tonRouteError(\"post\", error, {\n\t\t\t\t\t\tpath: `/blog/${slug}`,\n\t\t\t\t\t\tisSSR: typeof window === \"undefined\",\n\t\t\t\t\t\tslug,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}}\n\t\t/>\n\t);\n}\n", + "content": "\"use client\";\n\nimport { lazy, type ComponentType } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_PLUGIN_ID } from \"../../constants\";\nimport {\n\tComposedRoute,\n\tPermissionRouteAccess,\n} from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { PostLoading } from \"../loading\";\nimport { NotFoundPage } from \"./404-page\";\nimport { useSuspensePost } from \"@btst/stack/plugins/blog/client/hooks\";\nimport { blogPermissions } from \"../../../permissions\";\n\n// Share the guarded page between the lazy registry and native route entry.\nexport function createPostPage(\n\tPostPageContent: ComponentType<{ slug: string }>,\n) {\n\tfunction AuthorizedPostPage({ slug }: { slug: string }) {\n\t\tconst { post } = useSuspensePost(slug);\n\t\tconst permission = post\n\t\t\t? blogPermissions.post.read({\n\t\t\t\t\tscope: \"post\",\n\t\t\t\t\tslug: post.slug,\n\t\t\t\t\texists: true,\n\t\t\t\t\tid: post.id,\n\t\t\t\t\t...(post.authorId ? { authorId: post.authorId } : {}),\n\t\t\t\t\tpublished: post.published,\n\t\t\t\t})\n\t\t\t: blogPermissions.post.read({\n\t\t\t\t\tscope: \"post\",\n\t\t\t\t\tslug,\n\t\t\t\t\texists: false,\n\t\t\t\t\tpublished: false,\n\t\t\t\t});\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\t// Exported wrapped component with error and loading boundaries\n\tfunction PostPageComponent({ slug }: { slug: string }) {\n\t\tconst { onRouteError } =\n\t\t\tusePluginOverrides(BLOG_PLUGIN_ID);\n\t\treturn (\n\t\t\t {\n\t\t\t\t\tif (onRouteError) {\n\t\t\t\t\t\tonRouteError(\"post\", error, {\n\t\t\t\t\t\t\tpath: `/blog/${slug}`,\n\t\t\t\t\t\t\tisSSR: typeof window === \"undefined\",\n\t\t\t\t\t\t\tslug,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t/>\n\t\t);\n\t}\n\n\treturn PostPageComponent;\n}\n\nexport const PostPageComponent = createPostPage(\n\tlazy(() =>\n\t\timport(\"./post-page.internal\").then((m) => ({ default: m.PostPage })),\n\t),\n);\n", "target": "src/components/btst/blog/client/components/pages/post-page.tsx" }, { @@ -214,7 +214,7 @@ { "path": "btst/blog/client/components/pages/tag-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_PLUGIN_ID } from \"../../constants\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { PostsLoading } from \"../loading\";\nimport { NotFoundPage } from \"./404-page\";\nimport { blogPermissions } from \"../../../permissions\";\n\n// Lazy load the internal component with actual page content\nconst TagPage = lazy(() =>\n\timport(\"./tag-page.internal\").then((m) => ({ default: m.TagPage })),\n);\n\n// Exported wrapped component with error and loading boundaries\nexport function TagPageComponent({ tagSlug }: { tagSlug: string }) {\n\tconst { onRouteError } =\n\t\tusePluginOverrides(BLOG_PLUGIN_ID);\n\treturn (\n\t\t {\n\t\t\t\tif (onRouteError) {\n\t\t\t\t\tonRouteError(\"tag\", error, {\n\t\t\t\t\t\tpath: `/blog/tag/${tagSlug}`,\n\t\t\t\t\t\tisSSR: typeof window === \"undefined\",\n\t\t\t\t\t\ttagSlug,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}}\n\t\t/>\n\t);\n}\n", + "content": "\"use client\";\n\nimport { lazy, type ComponentType } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_PLUGIN_ID } from \"../../constants\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { PostsLoading } from \"../loading\";\nimport { NotFoundPage } from \"./404-page\";\nimport { blogPermissions } from \"../../../permissions\";\n\n// Share the guarded page between the lazy registry and native route entry.\nexport function createTagPage(TagPage: ComponentType<{ tagSlug: string }>) {\n\t// Exported wrapped component with error and loading boundaries\n\tfunction TagPageComponent({ tagSlug }: { tagSlug: string }) {\n\t\tconst { onRouteError } =\n\t\t\tusePluginOverrides(BLOG_PLUGIN_ID);\n\t\treturn (\n\t\t\t {\n\t\t\t\t\tif (onRouteError) {\n\t\t\t\t\t\tonRouteError(\"tag\", error, {\n\t\t\t\t\t\t\tpath: `/blog/tag/${tagSlug}`,\n\t\t\t\t\t\t\tisSSR: typeof window === \"undefined\",\n\t\t\t\t\t\t\ttagSlug,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t/>\n\t\t);\n\t}\n\n\treturn TagPageComponent;\n}\n\nexport const TagPageComponent = createTagPage(\n\tlazy(() =>\n\t\timport(\"./tag-page.internal\").then((m) => ({ default: m.TagPage })),\n\t),\n);\n", "target": "src/components/btst/blog/client/components/pages/tag-page.tsx" }, { @@ -379,6 +379,24 @@ "content": "import type { SerializedPost } from \"../types\";\nimport type { ComponentType, ReactNode } from \"react\";\nimport type { BlogLocalization } from \"./localization\";\n\n/**\n * Props for the overridable blog featured image input component.\n */\nexport interface BlogImageInputFieldProps {\n\t/** Current image URL value */\n\tvalue: string;\n\t/** Called when the image URL changes */\n\tonChange: (value: string) => void;\n\t/** Whether the field is required */\n\tisRequired?: boolean;\n}\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { slug: \"my-post\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: any;\n}\n\n/**\n * Overridable components and functions for the Blog plugin\n *\n * External consumers can provide their own implementations to customize\n * plugin-specific components and behavior.\n */\nexport interface BlogPluginOverrides {\n\t/**\n\t * Post card component for displaying a post\n\t */\n\tPostCard?: ComponentType<{\n\t\tpost: SerializedPost;\n\t}>;\n\t/**\n\t * Function used to upload a new image file and return its URL.\n\t * This is separate from `imagePicker`, which selects an existing asset URL.\n\t */\n\tuploadImage: (file: File) => Promise;\n\t/**\n\t * Optional custom component for the featured image field.\n\t *\n\t * When provided it replaces the default file-upload input entirely.\n\t * The component receives `value` (current URL string) and `onChange` (setter).\n\t *\n\t * Typical use case: render a preview when a value is set, and a media-picker\n\t * trigger when no value is set.\n\t *\n\t * @example\n\t * ```tsx\n\t * imageInputField: ({ value, onChange }) =>\n\t * value ? (\n\t *
\n\t * \"Preview\"\n\t * Change} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t *
\n\t * ) : (\n\t * Browse media} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t * )\n\t * ```\n\t */\n\timageInputField?: ComponentType;\n\n\t/**\n\t * Optional trigger component for a media picker.\n\t * When provided, it is rendered adjacent to the Markdown editor and allows\n\t * users to browse and select previously uploaded assets.\n\t * Receives `onSelect(url)` — insert the chosen URL into the editor.\n\t *\n\t * @example\n\t * ```tsx\n\t * imagePicker: ({ onSelect }) => (\n\t * Browse media}\n\t * accept={[\"image/*\"]}\n\t * onSelect={(assets) => onSelect(assets[0].url)}\n\t * />\n\t * )\n\t * ```\n\t */\n\timagePicker?: ComponentType<{ onSelect: (url: string) => void }>;\n\t/**\n\t * Localization object for the blog plugin\n\t */\n\tlocalization?: Partial;\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\t// Lifecycle Hooks (optional)\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'posts', 'post', 'newPost')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t// ============ Slot Overrides ============\n\n\t/**\n\t * Optional slot rendered below the blog post body.\n\t * Use this to inject a comment thread or any custom content without\n\t * coupling the blog plugin to the comments plugin.\n\t *\n\t * @example\n\t * ```tsx\n\t * blog: {\n\t * postBottomSlot: (post) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tpostBottomSlot?: (post: SerializedPost) => ReactNode;\n}\n", "target": "src/components/btst/blog/client/overrides.ts" }, + { + "path": "btst/blog/client/pages/post.tsx", + "type": "registry:page", + "content": "\"use client\";\n\nimport { createPostPage } from \"../components/pages/post-page\";\nimport { PostPage as Content } from \"../components/pages/post-page.internal\";\n\n/** Complete guarded page for a native framework route; contains no lazy page import. */\nexport const PostPage = createPostPage(Content);\n", + "target": "src/components/btst/blog/client/pages/post.tsx" + }, + { + "path": "btst/blog/client/pages/posts.tsx", + "type": "registry:page", + "content": "\"use client\";\n\nimport { createHomePage } from \"../components/pages/home-page\";\nimport { HomePage as Content } from \"../components/pages/home-page.internal\";\n\n/** Complete guarded page for a native framework route; contains no lazy page import. */\nexport const PostListPage = createHomePage(Content);\n", + "target": "src/components/btst/blog/client/pages/posts.tsx" + }, + { + "path": "btst/blog/client/pages/tag.tsx", + "type": "registry:page", + "content": "\"use client\";\n\nimport { createTagPage } from \"../components/pages/tag-page\";\nimport { TagPage as Content } from \"../components/pages/tag-page.internal\";\n\n/** Complete guarded page for a native framework route; contains no lazy page import. */\nexport const TagPage = createTagPage(Content);\n", + "target": "src/components/btst/blog/client/pages/tag.tsx" + }, { "path": "ui/components/form.tsx", "type": "registry:component", diff --git a/packages/stack/src/plugins/blog/__tests__/page-loading.test.tsx b/packages/stack/src/plugins/blog/__tests__/page-loading.test.tsx new file mode 100644 index 00000000..cfdda68a --- /dev/null +++ b/packages/stack/src/plugins/blog/__tests__/page-loading.test.tsx @@ -0,0 +1,106 @@ +import { renderToString } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; +import { StackProvider } from "@btst/stack/context"; +import { createClientStack } from "@btst/stack/client"; +import { blogClientPlugin } from "@btst/stack/plugins/blog/client"; +import { PostListPage } from "@btst/stack/plugins/blog/client/pages/posts"; +import { PostPage } from "@btst/stack/plugins/blog/client/pages/post"; +import { TagPage } from "@btst/stack/plugins/blog/client/pages/tag"; +import { defineAuthorization } from "@btst/stack/authorization"; +import { createClientAuth } from "@btst/stack/authorization/client"; +import { blogPermissions } from "@btst/stack/plugins/blog/permissions"; +import { z } from "zod"; +import { BLOG_QUERY_KEYS } from "../api/query-key-defs"; + +const post = { + id: "prefetched-post", + slug: "prefetched-post", + title: "Prefetched article", + content: "Article available before hydration.", + excerpt: "Article excerpt", + published: true, + tags: [], + image: null, + authorId: null, + createdAt: "2026-09-16T12:00:00.000Z", + updatedAt: "2026-09-16T12:00:00.000Z", + publishedAt: "2026-09-16T12:00:00.000Z", +}; + +const authorization = defineAuthorization({ + identity: z.object({ id: z.string() }), + permissions: [blogPermissions] as const, + rules: ({ blog }) => [ + blog.post.read.when( + ({ facts }) => + facts.scope === "published" || + (facts.scope === "post" && (!facts.exists || facts.published)), + ), + blog.tag.read.allow(), + ], +}); +const auth = createClientAuth({ authorization, getIdentity: () => null }); + +describe("blog pages in native framework routes", () => { + it.each(["list", "post", "tag", "draft list", "draft post", "missing post"])( + "renders prefetched %s with the existing public-access rules", + (page) => { + const fetch = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new Error("Unexpected fetch")); + const published = !page.startsWith("draft"); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + queryClient.setQueryData( + BLOG_QUERY_KEYS.postDetail(post.slug), + page === "missing post" ? null : { ...post, published }, + ); + queryClient.setQueryData( + BLOG_QUERY_KEYS.postsList({ + published, + ...(page === "tag" ? { tagSlug: "news" } : {}), + }), + { + pages: [[post]], + pageParams: [0], + }, + ); + queryClient.setQueryData(BLOG_QUERY_KEYS.tagsList(), [ + { id: "news", slug: "news", name: "News" }, + ]); + const stack = createClientStack({ + api: { baseURL: "http://test.local", basePath: "/api/data" }, + site: { baseURL: "http://test.local", basePath: "/pages" }, + queryClient, + plugins: { blog: blogClientPlugin() }, + }); + try { + const html = renderToString( + + + {page.endsWith("list") ? ( + + ) : page === "tag" ? ( + + ) : ( + + )} + + , + ); + if (published && page !== "missing post") + expect(html).toContain(post.title); + else expect(html).not.toContain(post.title); + if (page === "missing post") expect(html).toContain("does not exist"); + if (published) + expect(html).not.toMatch(/data-testid="(?:posts|post)-skeleton"/); + expect(fetch).not.toHaveBeenCalled(); + } finally { + queryClient.clear(); + fetch.mockRestore(); + } + }, + ); +}); diff --git a/packages/stack/src/plugins/blog/client/components/pages/home-page.tsx b/packages/stack/src/plugins/blog/client/components/pages/home-page.tsx index 05a6cddd..5695143f 100644 --- a/packages/stack/src/plugins/blog/client/components/pages/home-page.tsx +++ b/packages/stack/src/plugins/blog/client/components/pages/home-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { lazy } from "react"; +import { lazy, type ComponentType } from "react"; import { usePluginOverrides } from "@btst/stack/context"; import type { BlogPluginOverrides } from "../../overrides"; import { BLOG_PLUGIN_ID } from "../../constants"; @@ -10,39 +10,43 @@ import { PostsLoading } from "../loading"; import { NotFoundPage } from "./404-page"; import { blogPermissions } from "../../../permissions"; -// Lazy load the internal component with actual page content -const HomePage = lazy(() => - import("./home-page.internal").then((m) => ({ default: m.HomePage })), -); +// Share the guarded page between the lazy registry and native route entry. +export function createHomePage( + HomePage: ComponentType<{ published: boolean }>, +) { + // Exported wrapped component with error and loading boundaries + function HomePageComponent({ published = true }: { published?: boolean }) { + const { onRouteError } = + usePluginOverrides(BLOG_PLUGIN_ID); + return ( + { + if (onRouteError) { + onRouteError("posts", error, { + path: published ? "/blog" : "/blog/drafts", + isSSR: typeof window === "undefined", + published, + }); + } + }} + /> + ); + } -// Exported wrapped component with error and loading boundaries -export function HomePageComponent({ - published = true, -}: { - published?: boolean; -}) { - const { onRouteError } = - usePluginOverrides(BLOG_PLUGIN_ID); - return ( - { - if (onRouteError) { - onRouteError("posts", error, { - path: published ? "/blog" : "/blog/drafts", - isSSR: typeof window === "undefined", - published, - }); - } - }} - /> - ); + return HomePageComponent; } + +export const HomePageComponent = createHomePage( + lazy(() => + import("./home-page.internal").then((m) => ({ default: m.HomePage })), + ), +); diff --git a/packages/stack/src/plugins/blog/client/components/pages/post-page.tsx b/packages/stack/src/plugins/blog/client/components/pages/post-page.tsx index a42a52fe..8c202bc1 100644 --- a/packages/stack/src/plugins/blog/client/components/pages/post-page.tsx +++ b/packages/stack/src/plugins/blog/client/components/pages/post-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { lazy } from "react"; +import { lazy, type ComponentType } from "react"; import { usePluginOverrides } from "@btst/stack/context"; import type { BlogPluginOverrides } from "../../overrides"; import { BLOG_PLUGIN_ID } from "../../constants"; @@ -14,59 +14,67 @@ import { NotFoundPage } from "./404-page"; import { useSuspensePost } from "../../hooks/blog-hooks"; import { blogPermissions } from "../../../permissions"; -// Lazy load the internal component with actual page content -const PostPageContent = lazy(() => - import("./post-page.internal").then((m) => ({ default: m.PostPage })), -); +// Share the guarded page between the lazy registry and native route entry. +export function createPostPage( + PostPageContent: ComponentType<{ slug: string }>, +) { + function AuthorizedPostPage({ slug }: { slug: string }) { + const { post } = useSuspensePost(slug); + const permission = post + ? blogPermissions.post.read({ + scope: "post", + slug: post.slug, + exists: true, + id: post.id, + ...(post.authorId ? { authorId: post.authorId } : {}), + published: post.published, + }) + : blogPermissions.post.read({ + scope: "post", + slug, + exists: false, + published: false, + }); + return ( + + + + ); + } -function AuthorizedPostPage({ slug }: { slug: string }) { - const { post } = useSuspensePost(slug); - const permission = post - ? blogPermissions.post.read({ - scope: "post", - slug: post.slug, - exists: true, - id: post.id, - ...(post.authorId ? { authorId: post.authorId } : {}), - published: post.published, - }) - : blogPermissions.post.read({ - scope: "post", - slug, - exists: false, - published: false, - }); - return ( - - - - ); -} + // Exported wrapped component with error and loading boundaries + function PostPageComponent({ slug }: { slug: string }) { + const { onRouteError } = + usePluginOverrides(BLOG_PLUGIN_ID); + return ( + { + if (onRouteError) { + onRouteError("post", error, { + path: `/blog/${slug}`, + isSSR: typeof window === "undefined", + slug, + }); + } + }} + /> + ); + } -// Exported wrapped component with error and loading boundaries -export function PostPageComponent({ slug }: { slug: string }) { - const { onRouteError } = - usePluginOverrides(BLOG_PLUGIN_ID); - return ( - { - if (onRouteError) { - onRouteError("post", error, { - path: `/blog/${slug}`, - isSSR: typeof window === "undefined", - slug, - }); - } - }} - /> - ); + return PostPageComponent; } + +export const PostPageComponent = createPostPage( + lazy(() => + import("./post-page.internal").then((m) => ({ default: m.PostPage })), + ), +); diff --git a/packages/stack/src/plugins/blog/client/components/pages/tag-page.tsx b/packages/stack/src/plugins/blog/client/components/pages/tag-page.tsx index cab3a5de..ee78206f 100644 --- a/packages/stack/src/plugins/blog/client/components/pages/tag-page.tsx +++ b/packages/stack/src/plugins/blog/client/components/pages/tag-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { lazy } from "react"; +import { lazy, type ComponentType } from "react"; import { usePluginOverrides } from "@btst/stack/context"; import type { BlogPluginOverrides } from "../../overrides"; import { BLOG_PLUGIN_ID } from "../../constants"; @@ -10,33 +10,39 @@ import { PostsLoading } from "../loading"; import { NotFoundPage } from "./404-page"; import { blogPermissions } from "../../../permissions"; -// Lazy load the internal component with actual page content -const TagPage = lazy(() => - import("./tag-page.internal").then((m) => ({ default: m.TagPage })), -); +// Share the guarded page between the lazy registry and native route entry. +export function createTagPage(TagPage: ComponentType<{ tagSlug: string }>) { + // Exported wrapped component with error and loading boundaries + function TagPageComponent({ tagSlug }: { tagSlug: string }) { + const { onRouteError } = + usePluginOverrides(BLOG_PLUGIN_ID); + return ( + { + if (onRouteError) { + onRouteError("tag", error, { + path: `/blog/tag/${tagSlug}`, + isSSR: typeof window === "undefined", + tagSlug, + }); + } + }} + /> + ); + } -// Exported wrapped component with error and loading boundaries -export function TagPageComponent({ tagSlug }: { tagSlug: string }) { - const { onRouteError } = - usePluginOverrides(BLOG_PLUGIN_ID); - return ( - { - if (onRouteError) { - onRouteError("tag", error, { - path: `/blog/tag/${tagSlug}`, - isSSR: typeof window === "undefined", - tagSlug, - }); - } - }} - /> - ); + return TagPageComponent; } + +export const TagPageComponent = createTagPage( + lazy(() => + import("./tag-page.internal").then((m) => ({ default: m.TagPage })), + ), +); diff --git a/packages/stack/src/plugins/blog/client/pages/post.tsx b/packages/stack/src/plugins/blog/client/pages/post.tsx new file mode 100644 index 00000000..e26c2ee4 --- /dev/null +++ b/packages/stack/src/plugins/blog/client/pages/post.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { createPostPage } from "../components/pages/post-page"; +import { PostPage as Content } from "../components/pages/post-page.internal"; + +/** Complete guarded page for a native framework route; contains no lazy page import. */ +export const PostPage = createPostPage(Content); diff --git a/packages/stack/src/plugins/blog/client/pages/posts.tsx b/packages/stack/src/plugins/blog/client/pages/posts.tsx new file mode 100644 index 00000000..2ebb1b53 --- /dev/null +++ b/packages/stack/src/plugins/blog/client/pages/posts.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { createHomePage } from "../components/pages/home-page"; +import { HomePage as Content } from "../components/pages/home-page.internal"; + +/** Complete guarded page for a native framework route; contains no lazy page import. */ +export const PostListPage = createHomePage(Content); diff --git a/packages/stack/src/plugins/blog/client/pages/tag.tsx b/packages/stack/src/plugins/blog/client/pages/tag.tsx new file mode 100644 index 00000000..5f9a1e2f --- /dev/null +++ b/packages/stack/src/plugins/blog/client/pages/tag.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { createTagPage } from "../components/pages/tag-page"; +import { TagPage as Content } from "../components/pages/tag-page.internal"; + +/** Complete guarded page for a native framework route; contains no lazy page import. */ +export const TagPage = createTagPage(Content); From 70d318bbea763d4238e50723eca80b5d145e199b Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:00:29 -0400 Subject: [PATCH 2/7] test(blog): isolate code loading from session refresh --- e2e/tests/blog-loading.ssg.spec.ts | 5 +++-- .../nextjs/app/loading-blog/[slug]/page.tsx | 4 ++++ .../files/nextjs/app/loading-blog/layout.tsx | 20 +++++++++++++++++++ .../files/nextjs/app/loading-blog/page.tsx | 1 + 4 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 scripts/codegen/files/nextjs/app/loading-blog/[slug]/page.tsx create mode 100644 scripts/codegen/files/nextjs/app/loading-blog/layout.tsx create mode 100644 scripts/codegen/files/nextjs/app/loading-blog/page.tsx diff --git a/e2e/tests/blog-loading.ssg.spec.ts b/e2e/tests/blog-loading.ssg.spec.ts index 0dbbdb4e..4b181bd8 100644 --- a/e2e/tests/blog-loading.ssg.spec.ts +++ b/e2e/tests/blog-loading.ssg.spec.ts @@ -1,7 +1,8 @@ import { expect, test } from "@playwright/test"; import { mockAuthHeaders } from "./helpers/mock-auth"; -// Fresh anonymous contexts exercise cold hydration of the generated native routes. +// Reuse the generated native pages with a resolved anonymous identity. The regular +// SSG layout deliberately refetches identity, which has its own pending fallback. for (const route of ["list", "post"] as const) { test(`prefetched ${route} stays visible with slow JavaScript and split page bundles`, async ({ page, @@ -58,7 +59,7 @@ for (const route of ["list", "post"] as const) { }); try { await page.goto( - route === "list" ? "/pages/ssg-blog" : `/pages/ssg-blog/${slug}`, + route === "list" ? "/loading-blog" : `/loading-blog/${slug}`, { waitUntil: "networkidle", }, diff --git a/scripts/codegen/files/nextjs/app/loading-blog/[slug]/page.tsx b/scripts/codegen/files/nextjs/app/loading-blog/[slug]/page.tsx new file mode 100644 index 00000000..804b6a69 --- /dev/null +++ b/scripts/codegen/files/nextjs/app/loading-blog/[slug]/page.tsx @@ -0,0 +1,4 @@ +export { + default, + generateMetadata, +} from "../../(static)/pages/ssg-blog/[slug]/page"; diff --git a/scripts/codegen/files/nextjs/app/loading-blog/layout.tsx b/scripts/codegen/files/nextjs/app/loading-blog/layout.tsx new file mode 100644 index 00000000..d8755781 --- /dev/null +++ b/scripts/codegen/files/nextjs/app/loading-blog/layout.tsx @@ -0,0 +1,20 @@ +import { BtstPagesClientLayout } from "@/app/pages/client-layout"; +import { getServerClientOrigins } from "@/lib/stack-client.server"; +import type { ReactNode } from "react"; + +// Keep identity resolved so the loading regression measures page code, not the +// explicit session refresh exercised by the regular static layout's auth tests. +export default function LoadingBlogLayout({ + children, +}: { + children: ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/scripts/codegen/files/nextjs/app/loading-blog/page.tsx b/scripts/codegen/files/nextjs/app/loading-blog/page.tsx new file mode 100644 index 00000000..d4bdc67f --- /dev/null +++ b/scripts/codegen/files/nextjs/app/loading-blog/page.tsx @@ -0,0 +1 @@ +export { default, generateMetadata } from "../(static)/pages/ssg-blog/page"; From 5bf0dce4bdf21e9e11f7da011c4d421c54f1d95c Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:03:16 -0400 Subject: [PATCH 3/7] test(blog): reproduce provider updates during hydration --- e2e/tests/blog-loading.ssg.spec.ts | 4 ++++ .../nextjs/app/loading-blog/client-layout.tsx | 24 +++++++++++++++++++ .../files/nextjs/app/loading-blog/layout.tsx | 9 +++---- 3 files changed, 31 insertions(+), 6 deletions(-) create mode 100644 scripts/codegen/files/nextjs/app/loading-blog/client-layout.tsx diff --git a/e2e/tests/blog-loading.ssg.spec.ts b/e2e/tests/blog-loading.ssg.spec.ts index 4b181bd8..d414f8ec 100644 --- a/e2e/tests/blog-loading.ssg.spec.ts +++ b/e2e/tests/blog-loading.ssg.spec.ts @@ -70,6 +70,10 @@ for (const route of ["list", "post"] as const) { exact: true, }), ).toBeVisible(); + await expect(page.getByTestId("hydration-update")).toHaveAttribute( + "data-hydrated", + "true", + ); expect( await page.evaluate( () => diff --git a/scripts/codegen/files/nextjs/app/loading-blog/client-layout.tsx b/scripts/codegen/files/nextjs/app/loading-blog/client-layout.tsx new file mode 100644 index 00000000..4069728c --- /dev/null +++ b/scripts/codegen/files/nextjs/app/loading-blog/client-layout.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; +import { BtstPagesClientLayout } from "@/app/pages/client-layout"; +import type { StackClientOrigins } from "@/lib/stack-client"; + +export function LoadingBlogClientLayout({ + children, + clientOrigins, +}: { + children: ReactNode; + clientOrigins: StackClientOrigins; +}) { + const [hydrated, setHydrated] = useState(false); + // Model a provider update during hydration without changing authorization. + useEffect(() => setHydrated(true), []); + return ( + +
+ {children} +
+
+ ); +} diff --git a/scripts/codegen/files/nextjs/app/loading-blog/layout.tsx b/scripts/codegen/files/nextjs/app/loading-blog/layout.tsx index d8755781..cf1d7e0b 100644 --- a/scripts/codegen/files/nextjs/app/loading-blog/layout.tsx +++ b/scripts/codegen/files/nextjs/app/loading-blog/layout.tsx @@ -1,4 +1,4 @@ -import { BtstPagesClientLayout } from "@/app/pages/client-layout"; +import { LoadingBlogClientLayout } from "./client-layout"; import { getServerClientOrigins } from "@/lib/stack-client.server"; import type { ReactNode } from "react"; @@ -10,11 +10,8 @@ export default function LoadingBlogLayout({ children: ReactNode; }) { return ( - + {children} - + ); } From ffdb3c20e1e03cfdb2daf2b6c93f18561168627d Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:34:07 -0400 Subject: [PATCH 4/7] fix(context): preserve provider values during lazy page hydration --- .github/workflows/packed-consumers.yml | 1 + docs/content/docs/how-it-works.mdx | 17 ++ docs/content/docs/installation.mdx | 12 +- docs/content/docs/plugins/blog.mdx | 57 +++--- ....ssg.spec.ts => page-loading.blog.spec.ts} | 79 +++++++-- .../scripts/test-better-auth-ui-fixtures.mjs | 2 +- .../nextjs/pages-client-layout.tsx.hbs | 16 +- .../templates/nextjs/ssg-blog-list.tsx.hbs | 7 +- .../templates/nextjs/ssg-blog-post.tsx.hbs | 7 +- .../react-router/pages-layout.tsx.hbs | 16 +- .../templates/tanstack/pages-layout.tsx.hbs | 16 +- .../utils/__tests__/package-installer.test.ts | 4 +- .../src/utils/__tests__/scaffold-plan.test.ts | 2 +- packages/cli/src/utils/constants.ts | 2 +- packages/cli/src/utils/package-installer.ts | 2 +- .../src/__tests__/provider-hydration.test.tsx | 164 ++++++++++++++++++ packages/stack/src/context/auth.tsx | 35 ++-- packages/stack/src/context/provider.tsx | 79 ++++++--- .../blog/__tests__/page-load-error.test.tsx | 53 ++++++ .../files/nextjs/app/pages/client-layout.tsx | 109 ++++++------ .../react-router/app/routes/pages/_layout.tsx | 97 ++++++----- .../files/tanstack/src/routes/pages/route.tsx | 97 ++++++----- 22 files changed, 625 insertions(+), 249 deletions(-) rename e2e/tests/{blog-loading.ssg.spec.ts => page-loading.blog.spec.ts} (54%) create mode 100644 packages/stack/src/__tests__/provider-hydration.test.tsx create mode 100644 packages/stack/src/plugins/blog/__tests__/page-load-error.test.tsx diff --git a/.github/workflows/packed-consumers.yml b/.github/workflows/packed-consumers.yml index b51f27d5..81f0db3a 100644 --- a/.github/workflows/packed-consumers.yml +++ b/.github/workflows/packed-consumers.yml @@ -63,6 +63,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: repository: better-stack-ai/better-auth-ui + ref: ee40b301daffaeb642678685ba7a78e02c2f03d4 # 2.0.1-rc.1 companion path: .packed-consumer/better-auth-ui - name: Setup pnpm diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx index 908be7e7..84df78a1 100644 --- a/docs/content/docs/how-it-works.mdx +++ b/docs/content/docs/how-it-works.mdx @@ -143,6 +143,23 @@ const clientStack = createClientStack({ - **`auth`**: Resolve identity, provide a login path, and evaluate exact schema-backed permission descriptors. +Keep `stack` and `overrides` referentially stable across unrelated renders. +BTST preserves its context values while these inputs and the router services are +unchanged. Recreating override objects or callbacks can notify a lazy page during +hydration and reveal its loading fallback even when its data was prefetched. + +For values created inside a provider component, memoize them with their actual +dependencies: + +```tsx +const overrides = useMemo(() => ({ blog: { uploadImage } }), [uploadImage]) +``` + +Pass `overrides={overrides}` to `StackProvider`. Module-level constants are also +appropriate for configuration that does not depend on component state. Changed +identities, runtime values, and callbacks remain synchronous; memoization does +not retain a previous permission decision or callback after its inputs change. + The `overrides` object is only for plugin-specific customization such as upload functions, component slots, localization, and route analytics. SSR loader hooks, page choices, and metadata customization remain plugin-specific diff --git a/docs/content/docs/installation.mdx b/docs/content/docs/installation.mdx index 49b3dcb4..5f8583d1 100644 --- a/docs/content/docs/installation.mdx +++ b/docs/content/docs/installation.mdx @@ -787,6 +787,8 @@ In order to use BTST, your application must meet the following requirements: import { getStackClient, type StackClientOptions } from "@/lib/stack-client" import { uploadImage } from "@/lib/uploads" + const overrides = { blog: { uploadImage } } + export function PagesClientLayout({ children, clientOrigins }: { children: React.ReactNode clientOrigins: StackClientOptions @@ -802,7 +804,7 @@ In order to use BTST, your application must meet the following requirements: {children} @@ -844,6 +846,8 @@ In order to use BTST, your application must meet the following requirements: import { getServerClientOrigins } from "~/lib/stack-client.server" import { uploadImage } from "~/lib/uploads" + const overrides = { blog: { uploadImage } } + export function loader({ request }: LoaderFunctionArgs) { return getServerClientOrigins(new URL(request.url).origin) } @@ -861,7 +865,7 @@ In order to use BTST, your application must meet the following requirements: @@ -882,6 +886,8 @@ In order to use BTST, your application must meet the following requirements: import { getTrustedClientOrigins } from "@/lib/stack-client.origins" import { uploadImage } from "@/lib/uploads" + const overrides = { blog: { uploadImage } } + export const Route = createFileRoute("/pages")({ loader: async () => getTrustedClientOrigins(), component: PagesLayout, @@ -900,7 +906,7 @@ In order to use BTST, your application must meet the following requirements: diff --git a/docs/content/docs/plugins/blog.mdx b/docs/content/docs/plugins/blog.mdx index 71dcf7c5..14b2b6eb 100644 --- a/docs/content/docs/plugins/blog.mdx +++ b/docs/content/docs/plugins/blog.mdx @@ -710,11 +710,29 @@ dehydrated protected data into a public page. | `"newPost"` | — | *(nothing)* | | `"editPost"` | `{ slug: string }` | Post to edit | -### Pages in native framework routes +### Rendering prefetched pages -Data prefetching fills the query cache; it does not load a lazy page's JavaScript. -For separate framework route files, import the complete guarded page from its -own entry point: +Data prefetching fills the query cache; it does not download page JavaScript. +The existing router API remains supported and lazy loads each page's content: + +```tsx +{route.PageComponent && } +``` + +BTST memoizes provider context so unchanged BTST inputs do not trigger context +updates during lazy page hydration. Keep the resolved `stack` and `overrides` references +stable when their inputs have not changed; the generated layouts do this with +`useMemo`. Existing integrations should memoize override objects and their +callbacks with the dependencies they use. See [provider configuration](/how-it-works#resolved-client-runtime-and-provider-services). + +Actual identity, runtime, or override changes still take effect immediately. +They can reveal a loading state while page code or data is unavailable, as can +framework streaming or context updates from host providers above BTST. Provider +memoization does not preload the page module. Secondary sections such as recent +posts load independently. + +For separate framework route files, these direct entries import the complete +guarded page without the registry's inner lazy import: | Import path | Component | Props | |---|---|---| @@ -722,21 +740,17 @@ own entry point: | `@btst/stack/plugins/blog/client/pages/post` | `PostPage` | `slug: string` | | `@btst/stack/plugins/blog/client/pages/tag` | `TagPage` | `tagSlug: string` | -These entries include the selected page's content directly and retain the same -permission checks, error boundaries, loading states for missing data, and route -hooks as the built-in routes. They still require `StackProvider` and a hydrated -`QueryClientProvider`. Prefetch the corresponding route's data before rendering. +```tsx +import { PostPage } from "@btst/stack/plugins/blog/client/pages/post" -Import each entry in its own route file, not in a shared provider or client-stack -configuration: the framework can then load just that page and its shared -dependencies. BTST's generic route registry and the existing `client/components` -exports continue to lazy-load page content. Direct page imports render the -built-in UI; they do not apply `pageComponents` replacements configured on the -registry. Use your replacement component in the route file when applicable. + +``` -This avoids the additional lazy page suspension when hydrating prefetched content. -Framework streaming, unresolved authorization, and missing data can still show a -loading boundary; secondary sections such as recent posts load independently. +Both approaches use the same permission checks, error boundaries, and route hooks. +They require `StackProvider`, `QueryClientProvider`, and the corresponding hydrated +data. Import each direct entry in its own route file so the framework can load +that page and its shared dependencies. Direct imports render the built-in UI; +`pageComponents` replacements apply through the router only. ### Next.js example @@ -746,7 +760,6 @@ import { getOrCreateQueryClient } from "@/lib/query-client" import { getStackClient } from "@/lib/stack-client" import { myStack } from "@/lib/stack" import { metaElementsToObject, normalizePath } from "@btst/stack/client" -import { PostListPage } from "@btst/stack/plugins/blog/client/pages/posts" import type { Metadata } from "next" // Opt into SSG — Next.js generates this page at build time @@ -767,11 +780,12 @@ export async function generateMetadata(): Promise { export default async function BlogListPage() { const queryClient = getOrCreateQueryClient() + const route = getStackClient(queryClient).router.getRoute("/blog") // Reads directly from DB — works at build time, no HTTP server required await myStack.raw.blog.prefetchForRoute("posts", queryClient) return ( - + {route?.PageComponent && } ) } @@ -780,8 +794,6 @@ export default async function BlogListPage() { For individual post pages, also generate the static params list: ```tsx title="app/pages/blog/[slug]/page.tsx" -import { PostPage } from "@btst/stack/plugins/blog/client/pages/post" - export async function generateStaticParams() { const { items } = await myStack.trusted.blog.listPosts({ published: true, limit: 1000 }) return items.map((p) => ({ slug: p.slug })) @@ -790,10 +802,11 @@ export async function generateStaticParams() { export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params const queryClient = getOrCreateQueryClient() + const route = getStackClient(queryClient).router.getRoute(`/blog/${slug}`) await myStack.raw.blog.prefetchForRoute("post", queryClient, { slug }) return ( - + {route?.PageComponent && } ) } diff --git a/e2e/tests/blog-loading.ssg.spec.ts b/e2e/tests/page-loading.blog.spec.ts similarity index 54% rename from e2e/tests/blog-loading.ssg.spec.ts rename to e2e/tests/page-loading.blog.spec.ts index d414f8ec..581f222a 100644 --- a/e2e/tests/blog-loading.ssg.spec.ts +++ b/e2e/tests/page-loading.blog.spec.ts @@ -1,13 +1,17 @@ import { expect, test } from "@playwright/test"; import { mockAuthHeaders } from "./helpers/mock-auth"; -// Reuse the generated native pages with a resolved anonymous identity. The regular -// SSG layout deliberately refetches identity, which has its own pending fallback. -for (const route of ["list", "post"] as const) { - test(`prefetched ${route} stays visible with slow JavaScript and split page bundles`, async ({ +// Exercise the existing router API with a provider update during hydration. +// Next uses a resolved anonymous identity fixture to isolate code loading. +for (const [route, failChunk] of [ + ["list", false], + ["post", false], + ["post", true], +] as const) { + test(`prefetched ${route} ${failChunk ? "keeps the blog error UI when its code fails to load" : "stays visible with slow JavaScript and split page bundles"}`, async ({ page, request, - }) => { + }, testInfo) => { const slug = `page-loading-${route}-${Date.now()}`; const title = `Prefetched ${slug}`; const created = await request.post("/api/data/posts", { @@ -22,6 +26,7 @@ for (const route of ["list", "post"] as const) { }); expect(created.ok(), await created.text()).toBeTruthy(); const post = await created.json(); + let blockedPageChunk = false; const errors: string[] = []; const scripts: Promise[] = []; page.on("pageerror", (error) => errors.push(error.message)); @@ -30,21 +35,38 @@ for (const route of ["list", "post"] as const) { scripts.push(response.text()); } }); - await page.route("**/_next/**/*.js", async (request) => { + await page.route("**/*.js", async (request) => { await new Promise((resolve) => setTimeout(resolve, 750)); - await request.continue(); + if (failChunk) { + const response = await request.fetch(); + if ((await response.text()).includes("Summarize this post")) { + blockedPageChunk = true; + await request.abort(); + } else { + await request.fulfill({ response }); + } + } else { + await request.continue(); + } }); await page.addInitScript(() => { - const state = { sawContent: false, skeletonAfterContent: false }; + const state = { + sawContent: false, + skeletonAfterContent: false, + contentHiddenAfterContent: false, + }; Object.assign(window, { blogLoadingState: state }); const visible = (selector: string) => Array.from(document.querySelectorAll(selector)).some( (element) => element.getBoundingClientRect().height > 0, ); function sample() { - if (visible('[data-testid="home-page"], [data-testid="post-page"]')) { - state.sawContent = true; - } + const hasContent = visible( + '[data-testid="home-page"], [data-testid="post-page"]', + ); + if (state.sawContent && !hasContent) + state.contentHiddenAfterContent = true; + if (hasContent) state.sawContent = true; if ( state.sawContent && visible( @@ -58,12 +80,18 @@ for (const route of ["list", "post"] as const) { requestAnimationFrame(sample); }); try { - await page.goto( - route === "list" ? "/loading-blog" : `/loading-blog/${slug}`, - { - waitUntil: "networkidle", - }, - ); + const basePath = testInfo.project.name.startsWith("nextjs") + ? "/loading-blog" + : "/pages/blog"; + await page.goto(route === "list" ? basePath : `${basePath}/${slug}`, { + waitUntil: "networkidle", + }); + if (failChunk) { + expect(blockedPageChunk).toBe(true); + await expect(page.getByTestId("error-placeholder")).toBeVisible(); + await expect(page.getByTestId("post-page")).not.toBeVisible(); + return; + } await expect( page.getByRole("heading", { name: route === "list" ? "Blog Posts" : title, @@ -82,11 +110,16 @@ for (const route of ["list", "post"] as const) { blogLoadingState: { sawContent: boolean; skeletonAfterContent: boolean; + contentHiddenAfterContent: boolean; }; } ).blogLoadingState, ), - ).toEqual({ sawContent: true, skeletonAfterContent: false }); + ).toEqual({ + sawContent: true, + skeletonAfterContent: false, + contentHiddenAfterContent: false, + }); expect(errors).toEqual([]); const loadedCode = (await Promise.all(scripts)).join("\n"); // These UI implementations belong to other lazy routes in the same stack. @@ -95,8 +128,16 @@ for (const route of ["list", "post"] as const) { "cms-list-search", "task-detail-bottom-slot", ]) { - expect(loadedCode).not.toContain(marker); + expect( + loadedCode.includes(marker), + `unexpected page code: ${marker}`, + ).toBe(false); } + if (route === "list") + expect( + loadedCode.includes("Summarize this post"), + "list loaded the post implementation", + ).toBe(false); } finally { const deleted = await request.delete(`/api/data/posts/${post.id}`, { headers: mockAuthHeaders(), diff --git a/packages/cli/scripts/test-better-auth-ui-fixtures.mjs b/packages/cli/scripts/test-better-auth-ui-fixtures.mjs index f62378cf..aea62a26 100644 --- a/packages/cli/scripts/test-better-auth-ui-fixtures.mjs +++ b/packages/cli/scripts/test-better-auth-ui-fixtures.mjs @@ -19,7 +19,7 @@ const SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url)); const CLI_DIRECTORY = resolve(SCRIPT_DIRECTORY, ".."); const REPOSITORY_ROOT = resolve(CLI_DIRECTORY, "../.."); const SHADCN_VERSION = "4.0.5"; -const BETTER_AUTH_UI_VERSION = "2.0.0"; +const BETTER_AUTH_UI_VERSION = "2.0.1-rc.1"; const FRAMEWORKS = ["nextjs", "react-router", "tanstack"]; const AUTH_COHORT = Object.freeze({ diff --git a/packages/cli/src/templates/nextjs/pages-client-layout.tsx.hbs b/packages/cli/src/templates/nextjs/pages-client-layout.tsx.hbs index d1d54ca2..fcf8040b 100644 --- a/packages/cli/src/templates/nextjs/pages-client-layout.tsx.hbs +++ b/packages/cli/src/templates/nextjs/pages-client-layout.tsx.hbs @@ -1,6 +1,7 @@ "use client" import { StackProvider, useIdentity, type StackIdentity } from "@btst/stack/context" +import type { ClientStackOverrides } from "@btst/stack/context" import { nextRouter } from "@btst/stack/next" {{#if hasAiChat}} import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" @@ -45,6 +46,15 @@ export function BtstPagesClientLayout({ const pathname = usePathname() const showChatWidget = !pathname.startsWith("/pages/chat") {{/if}} +{{#if pagesLayoutOverrides}} + const overrides = useMemo>( + () => ({ +{{{pagesLayoutOverrides}}} + }), + [{{#if hasBetterAuthUi}}authClient, frameworkRouter{{/if}}], + ) +{{/if}} + return ( {{#if hasAiChat}} diff --git a/packages/cli/src/templates/nextjs/ssg-blog-list.tsx.hbs b/packages/cli/src/templates/nextjs/ssg-blog-list.tsx.hbs index a697ae70..16c4965e 100644 --- a/packages/cli/src/templates/nextjs/ssg-blog-list.tsx.hbs +++ b/packages/cli/src/templates/nextjs/ssg-blog-list.tsx.hbs @@ -6,11 +6,11 @@ * (lib/stack.ts) to purge the cache when posts change. */ import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { notFound } from "next/navigation" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" import { getStackClient } from "{{alias}}lib/stack-client" import { myStack } from "{{alias}}lib/stack" import { metaElementsToObject, normalizePath } from "@btst/stack/client" -import { PostListPage } from "@btst/stack/plugins/blog/client/pages/posts" import type { Metadata } from "next" export async function generateStaticParams() { @@ -30,10 +30,13 @@ export async function generateMetadata(): Promise { export default async function SsgBlogListPage() { const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["blog"])) + if (!route) notFound() await myStack.raw.blog.prefetchForRoute("posts", queryClient) return ( - + {route.PageComponent && } ) } diff --git a/packages/cli/src/templates/nextjs/ssg-blog-post.tsx.hbs b/packages/cli/src/templates/nextjs/ssg-blog-post.tsx.hbs index 5af3d462..f1d06376 100644 --- a/packages/cli/src/templates/nextjs/ssg-blog-post.tsx.hbs +++ b/packages/cli/src/templates/nextjs/ssg-blog-post.tsx.hbs @@ -6,11 +6,11 @@ * plugin hooks (lib/stack.ts) to purge the cache when a post changes. */ import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { notFound } from "next/navigation" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" import { getStackClient } from "{{alias}}lib/stack-client" import { myStack } from "{{alias}}lib/stack" import { normalizePath, metaElementsToObject } from "@btst/stack/client" -import { PostPage } from "@btst/stack/plugins/blog/client/pages/post" import type { Metadata } from "next" export async function generateStaticParams() { @@ -41,10 +41,13 @@ export default async function SsgBlogPostPage({ }) { const { slug } = await params const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["blog", slug])) + if (!route) notFound() await myStack.raw.blog.prefetchForRoute("post", queryClient, { slug }) return ( - + {route.PageComponent && } ) } diff --git a/packages/cli/src/templates/react-router/pages-layout.tsx.hbs b/packages/cli/src/templates/react-router/pages-layout.tsx.hbs index 3fcf2538..e1acb5d2 100644 --- a/packages/cli/src/templates/react-router/pages-layout.tsx.hbs +++ b/packages/cli/src/templates/react-router/pages-layout.tsx.hbs @@ -1,4 +1,5 @@ import { StackProvider } from "@btst/stack/context" +import type { ClientStackOverrides } from "@btst/stack/context" import { reactRouter } from "@btst/stack/react-router" {{#if hasAiChat}} import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" @@ -36,17 +37,22 @@ export default function BtstPagesLayout() { const location = useLocation() const showChatWidget = !location.pathname.startsWith("/pages/chat") {{/if}} +{{#if pagesLayoutOverrides}} + const overrides = useMemo>( + () => ({ +{{{pagesLayoutOverrides}}} + }), + [{{#if hasBetterAuthUi}}authClient, revalidator.revalidate{{/if}}], + ) +{{/if}} + return ( {{#if hasAiChat}} diff --git a/packages/cli/src/templates/tanstack/pages-layout.tsx.hbs b/packages/cli/src/templates/tanstack/pages-layout.tsx.hbs index 38a6e8b3..494c369b 100644 --- a/packages/cli/src/templates/tanstack/pages-layout.tsx.hbs +++ b/packages/cli/src/templates/tanstack/pages-layout.tsx.hbs @@ -1,5 +1,6 @@ import { createFileRoute, Outlet{{#if hasAiChat}}, useLocation{{/if}}{{#if hasBetterAuthUi}}, useRouter{{/if}} } from "@tanstack/react-router" import { StackProvider } from "@btst/stack/context" +import type { ClientStackOverrides } from "@btst/stack/context" import { tanstackRouter } from "@btst/stack/tanstack" {{#if hasAiChat}} import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" @@ -37,17 +38,22 @@ function BtstPagesLayout() { const location = useLocation() const showChatWidget = !location.pathname.startsWith("/pages/chat") {{/if}} +{{#if pagesLayoutOverrides}} + const overrides = useMemo>( + () => ({ +{{{pagesLayoutOverrides}}} + }), + [{{#if hasBetterAuthUi}}authClient, frameworkRouter{{/if}}], + ) +{{/if}} + return ( {{#if hasAiChat}} diff --git a/packages/cli/src/utils/__tests__/package-installer.test.ts b/packages/cli/src/utils/__tests__/package-installer.test.ts index 8cd182d1..f4d23766 100644 --- a/packages/cli/src/utils/__tests__/package-installer.test.ts +++ b/packages/cli/src/utils/__tests__/package-installer.test.ts @@ -21,11 +21,11 @@ describe("installInitDependencies", () => { }); const installArguments = execa.mock.calls[0]?.[1] as string[]; - expect(installArguments).toContain("@btst/stack@3.0.2"); + expect(installArguments).toContain("@btst/stack@3.1.0-rc.1"); expect(installArguments).toContain("next-themes@0.4.6"); expect(installArguments).toContain("@btst/adapter-drizzle@2.2.3"); expect(installArguments).toContain("drizzle-orm@0.45.2"); - expect(installArguments).toContain("@btst/better-auth-ui@2.0.0"); + expect(installArguments).toContain("@btst/better-auth-ui@2.0.1-rc.1"); expect(installArguments).toContain("better-auth@1.6.16"); expect(installArguments).toContain("@better-auth/core@1.6.16"); expect(installArguments).toContain("@better-auth/utils@0.4.1"); diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index ba42e777..c9ad82e8 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -1053,7 +1053,7 @@ describe("scaffold plan", () => { expect(provider?.content).not.toContain("passkey:"); expect(plan.cssImports).toContain("@btst/better-auth-ui/css"); expect(plan.extraPackageVersions).toMatchObject({ - "@btst/better-auth-ui": "2.0.0", + "@btst/better-auth-ui": "2.0.1-rc.1", "better-auth": "1.6.16", }); }, diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index 9d3031e5..1079ffdf 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -206,7 +206,7 @@ export const PLUGINS: readonly PluginMeta[] = [ "@better-auth/passkey", ], extraInstallSpecs: [ - "@btst/better-auth-ui@2.0.0", + "@btst/better-auth-ui@2.0.1-rc.1", "better-auth@1.6.16", "@better-auth/core@1.6.16", "@better-auth/utils@0.4.1", diff --git a/packages/cli/src/utils/package-installer.ts b/packages/cli/src/utils/package-installer.ts index 0f1ab682..9954cc14 100644 --- a/packages/cli/src/utils/package-installer.ts +++ b/packages/cli/src/utils/package-installer.ts @@ -44,7 +44,7 @@ export async function installInitDependencies(input: { }); const packages = [ - "@btst/stack@3.0.2", + "@btst/stack@3.1.0-rc.1", "@btst/yar@1.3.2", "@tanstack/react-query@5.100.14", "next-themes@0.4.6", diff --git a/packages/stack/src/__tests__/provider-hydration.test.tsx b/packages/stack/src/__tests__/provider-hydration.test.tsx new file mode 100644 index 00000000..2cc2c0d3 --- /dev/null +++ b/packages/stack/src/__tests__/provider-hydration.test.tsx @@ -0,0 +1,164 @@ +// @vitest-environment jsdom +import { + act, + createContext, + lazy, + useContext, + useEffect, + useLayoutEffect, + useState, + type ReactNode, + type ComponentType, +} from "react"; +import { createRoot, hydrateRoot } from "react-dom/client"; +import { renderToString } from "react-dom/server"; +import { QueryClient } from "@tanstack/react-query"; +import { expect, it, vi } from "vitest"; +import { z } from "zod"; +import { + defineAuthorization, + definePermissions, + permission, +} from "../authorization"; +import { createClientAuth } from "../authorization/client"; +import { createClientStack } from "../client"; +import { ComposedRoute } from "../client/components/compose"; +import { StackProvider, useStack } from "../context"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const permissions = definePermissions("records", { + read: permission(z.object({ id: z.string() })), +}); +const authorization = defineAuthorization({ + identity: z.object({ id: z.string() }), + permissions: [permissions], + rules: ({ records }) => [ + records.read.when(({ identity }) => identity?.id === "owner"), + ], +}); +const auth = createClientAuth({ authorization, getIdentity: () => null }); +const owner = { id: "owner" }; +const access = permissions.read({ id: "one" }); +const navigate = () => {}; +const useRouter = () => undefinedRouter; +const undefinedRouter = {}; +const onError = () => {}; +const Content = () =>
Prefetched content
; +const Loading = () =>
Loading
; +const ErrorPage = () =>
Denied
; + +function createStack() { + return createClientStack({ + api: { baseURL: "http://test.local", basePath: "/api/data" }, + site: { baseURL: "http://test.local", basePath: "/pages" }, + queryClient: new QueryClient(), + plugins: {}, + }); +} + +it.each([false, true])( + "keeps pending page hydration safe when a provider rerenders (revoke: %s)", + async (revoke) => { + const stack = createStack(); + function Provider({ children }: { children: ReactNode }) { + const [updated, setUpdated] = useState(false); + useEffect(() => setUpdated(true), []); + return ( + + {updated ? "updated" : "initial"} + {children} + + ); + } + const tree = (Page: ComponentType) => ( + + + + ); + const container = document.createElement("div"); + container.innerHTML = renderToString(tree(Content)); + expect(container.querySelector("[data-content]")).not.toBeNull(); + const Pending = lazy( + () => new Promise<{ default: typeof Content }>(() => {}), + ); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const onRecoverableError = vi.fn(); + let root: ReturnType | undefined; + try { + await act(async () => { + root = hydrateRoot(container, tree(Pending), { onRecoverableError }); + }); + expect(container.querySelector("output")?.textContent).toBe("updated"); + expect(container.querySelector("[data-content]") !== null).toBe(!revoke); + expect(container.querySelector("[data-denied]") !== null).toBe(revoke); + expect(container.querySelector("[data-loading]")).toBeNull(); + expect(onRecoverableError).not.toHaveBeenCalled(); + } finally { + await act(async () => root?.unmount()); + stack.provider.queryClient.clear(); + consoleError.mockRestore(); + consoleWarn.mockRestore(); + } + }, +); + +it("applies new router callbacks with the current host context before page effects", async () => { + const Workspace = createContext("A"); + const stack = createStack(); + const observations: string[] = []; + function Page() { + const workspace = useContext(Workspace); + const { router } = useStack(); + useLayoutEffect(() => { + void router?.navigate?.(workspace); + }, [workspace]); + return null; + } + function App() { + const [workspace, setWorkspace] = useState("A"); + useEffect(() => setWorkspace("B"), []); + return ( + + { + observations.push(`${current}:${workspace}`); + }, + }} + > + + + + ); + } + const root = createRoot(document.createElement("div")); + try { + await act(async () => root.render()); + expect(observations).toEqual(["A:A", "B:B"]); + } finally { + await act(async () => root.unmount()); + stack.provider.queryClient.clear(); + } +}); diff --git a/packages/stack/src/context/auth.tsx b/packages/stack/src/context/auth.tsx index 0527cdce..a21a8d64 100644 --- a/packages/stack/src/context/auth.tsx +++ b/packages/stack/src/context/auth.tsx @@ -193,21 +193,28 @@ export function StackAuthBoundary({ void resolveIdentity(false); }, [initialIdentity, resolveIdentity]); - return ( - - {children} - + const value = useMemo( + () => ({ + provider, + identity: currentState.identity, + isPending: currentState.isPending, + sourceGeneration: currentResolutionGeneration, + ...(currentState.error ? { error: currentState.error } : {}), + refetch, + waitForResolution, + }), + [ + provider, + currentState.identity, + currentState.isPending, + currentResolutionGeneration, + currentState.error, + refetch, + waitForResolution, + ], ); + + return {children}; } /** @internal Access the raw auth context (or `null` when no provider is set). */ diff --git a/packages/stack/src/context/provider.tsx b/packages/stack/src/context/provider.tsx index 173c6f87..d6db2e14 100644 --- a/packages/stack/src/context/provider.tsx +++ b/packages/stack/src/context/provider.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, + useMemo, type ReactElement, type ReactNode, } from "react"; @@ -79,6 +80,10 @@ type StackProviderServices = { i18n?: StackI18nProvider; }; +/** Plugin override configuration inferred from a resolved client stack. */ +export type ClientStackOverrides> = + InferredPluginOverrides>; + type CanonicalStackProviderOverrideProps< TStack extends ResolvedClientStack, > = InferredPluginOverrides< @@ -110,12 +115,24 @@ function stripUndefined>(obj: T): Partial { return result as Partial; } -function resolveStaticRouter( - router: StackRouterConfig | undefined, -): StackRouter | undefined { - if (!router) return undefined; - const { useRouter: _useRouter, ...staticFields } = router; - return stripUndefined(staticFields); +function useStaticRouter(router: StackRouterConfig | undefined) { + const { Link, Image, navigate, refresh, getSearchParams, setSearchParams } = + router ?? {}; + const present = router !== undefined; + return useMemo( + () => + present + ? stripUndefined({ + Link, + Image, + navigate, + refresh, + getSearchParams, + setSearchParams, + }) + : undefined, + [present, Link, Image, navigate, refresh, getSearchParams, setSearchParams], + ); } /** @@ -135,15 +152,16 @@ function RouterBridge({ children?: ReactNode; }) { const hookRouter = useRouter(); - const router: StackRouter = { - ...staticRouter, - ...stripUndefined(hookRouter), - }; + const context = useMemo( + () => ({ + ...value, + router: { ...staticRouter, ...stripUndefined(hookRouter) }, + }), + [value, staticRouter, hookRouter], + ); return ( - - {children} - + {children} ); } @@ -205,18 +223,25 @@ export function StackProvider< i18n, }: CanonicalStackProviderProps): ReactElement { const projection = stack.provider; - const staticRouter = resolveStaticRouter(router); - const value: Omit, "router"> = { - overrides: overrides ?? {}, - basePath: projection.site.basePath, - api: projection.api, - site: projection.site, - plugins: projection.plugins, - queryClient: projection.queryClient, - clientStackContext: stack.context, - resolvedStack: stack, - auth, - }; + const staticRouter = useStaticRouter(router); + const value = useMemo( + () => ({ + overrides: overrides ?? {}, + basePath: projection.site.basePath, + api: projection.api, + site: projection.site, + plugins: projection.plugins, + queryClient: projection.queryClient, + clientStackContext: stack.context, + resolvedStack: stack, + auth, + }), + [overrides, stack, auth], + ); + const context = useMemo( + () => ({ ...value, router: staticRouter }), + [value, staticRouter], + ); const content = auth ? ( @@ -235,9 +260,7 @@ export function StackProvider< {content} ) : ( - - {content} - + {content} ); return ( diff --git a/packages/stack/src/plugins/blog/__tests__/page-load-error.test.tsx b/packages/stack/src/plugins/blog/__tests__/page-load-error.test.tsx new file mode 100644 index 00000000..a866168c --- /dev/null +++ b/packages/stack/src/plugins/blog/__tests__/page-load-error.test.tsx @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +import { act, lazy } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { expect, it, vi } from "vitest"; +import { createClientStack } from "@btst/stack/client"; +import { StackProvider } from "@btst/stack/context"; +import { blogClientPlugin } from "@btst/stack/plugins/blog/client"; +import { createHomePage } from "../client/components/pages/home-page"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +it("reports a failed page module through the existing blog error hook and UI", async () => { + const error = new Error("Page module unavailable"); + const Page = createHomePage(lazy(() => Promise.reject(error))); + const onRouteError = vi.fn(); + const queryClient = new QueryClient(); + const stack = createClientStack({ + api: { baseURL: "http://test.local", basePath: "/api/data" }, + site: { baseURL: "http://test.local", basePath: "/pages" }, + queryClient, + plugins: { blog: blogClientPlugin() }, + }); + const container = document.createElement("div"); + const root = createRoot(container); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + await act(async () => + root.render( + + "" } }} + > + + + , + ), + ); + expect( + container.querySelector('[data-testid="error-placeholder"]'), + ).not.toBeNull(); + expect(onRouteError).toHaveBeenCalledExactlyOnceWith("posts", error, { + path: "/blog", + isSSR: false, + published: true, + }); + } finally { + await act(async () => root.unmount()); + queryClient.clear(); + consoleError.mockRestore(); + } +}); diff --git a/scripts/codegen/files/nextjs/app/pages/client-layout.tsx b/scripts/codegen/files/nextjs/app/pages/client-layout.tsx index 5e9b6145..37e47689 100644 --- a/scripts/codegen/files/nextjs/app/pages/client-layout.tsx +++ b/scripts/codegen/files/nextjs/app/pages/client-layout.tsx @@ -1,6 +1,10 @@ "use client"; import React, { useEffect, useState } from "react"; -import { StackProvider, useIdentity } from "@btst/stack/context"; +import { + StackProvider, + type ClientStackOverrides, + useIdentity, +} from "@btst/stack/context"; import { nextRouter } from "@btst/stack/next"; import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; @@ -63,6 +67,59 @@ export function BtstPagesClientLayout({ [], ); + const overrides = React.useMemo>( + () => ({ + // Only genuinely plugin-specific overrides remain — the shared + // router and resolved runtime come from the provider props above. + blog: { + uploadImage, + imagePicker: ImagePicker, + imageInputField: ImageInputField, + // Wire comments into the bottom of each blog post + postBottomSlot: (post) => ( + + ), + }, + aiChat: { + uploadFile: uploadFileForChat, + chatSuggestions: [ + "How do Blog and Comments share the same request context?", + "Which BTST plugins include both backend and client registrations?", + "What stays under my control after I install a plugin?", + "Show me the routes added by Form Builder.", + "How can I customize an ejected view?", + ], + }, + cms: { + uploadImage, + imagePicker: ImagePicker, + imageInputField: ImageInputField, + }, + kanban: { + uploadImage, + imagePicker: ImagePicker, + // User resolution for assignees + resolveUser, + searchUsers, + // Wire comments into the bottom of each task detail dialog + taskDetailBottomSlot: (task) => ( + + ), + }, + comments: { + defaultCommentPageSize: 5, + resourceLinks: { + "blog-post": (slug) => `/pages/blog/${slug}`, + }, + }, + }), + [uploadImage, uploadFileForChat], + ); + return ( @@ -71,55 +128,7 @@ export function BtstPagesClientLayout({ router={nextRouter()} auth={clientAuth} initialIdentity={initialIdentity} - overrides={{ - // Only genuinely plugin-specific overrides remain — the shared - // router and resolved runtime come from the provider props above. - blog: { - uploadImage, - imagePicker: ImagePicker, - imageInputField: ImageInputField, - // Wire comments into the bottom of each blog post - postBottomSlot: (post) => ( - - ), - }, - aiChat: { - uploadFile: uploadFileForChat, - chatSuggestions: [ - "How do Blog and Comments share the same request context?", - "Which BTST plugins include both backend and client registrations?", - "What stays under my control after I install a plugin?", - "Show me the routes added by Form Builder.", - "How can I customize an ejected view?", - ], - }, - cms: { - uploadImage, - imagePicker: ImagePicker, - imageInputField: ImageInputField, - }, - kanban: { - uploadImage, - imagePicker: ImagePicker, - // User resolution for assignees - resolveUser, - searchUsers, - // Wire comments into the bottom of each task detail dialog - taskDetailBottomSlot: (task) => ( - - ), - }, - comments: { - defaultCommentPageSize: 5, - resourceLinks: { - "blog-post": (slug) => `/pages/blog/${slug}`, - }, - }, - }} + overrides={overrides} > {resolveIdentityAfterHydration && } {children} diff --git a/scripts/codegen/files/react-router/app/routes/pages/_layout.tsx b/scripts/codegen/files/react-router/app/routes/pages/_layout.tsx index 78c56be0..08485cb9 100644 --- a/scripts/codegen/files/react-router/app/routes/pages/_layout.tsx +++ b/scripts/codegen/files/react-router/app/routes/pages/_layout.tsx @@ -1,6 +1,6 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Outlet, useLoaderData, type LoaderFunctionArgs } from "react-router"; -import { StackProvider } from "@btst/stack/context"; +import { StackProvider, type ClientStackOverrides } from "@btst/stack/context"; import { createReactRouterLayout, reactRouter } from "@btst/stack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { ChatLayout } from "@btst/stack/plugins/ai-chat/client"; @@ -29,6 +29,8 @@ export async function loader(args: LoaderFunctionArgs) { } export default function Layout() { + const [hydrated, setHydrated] = useState(false); + useEffect(() => setHydrated(true), []); const { apiOrigin, initialIdentity, siteOrigin } = useLoaderData(); const queryClient = useQueryClient(); @@ -62,55 +64,62 @@ export default function Layout() { [], ); + const overrides = useMemo>( + () => ({ + // Only genuinely plugin-specific overrides remain — the shared + // router and resolved runtime come from the provider props above. + blog: { + uploadImage, + imagePicker: ImagePicker, + imageInputField: ImageInputField, + // Wire comments into the bottom of each blog post + postBottomSlot: (post) => ( + + ), + }, + aiChat: { + uploadFile: uploadFileForChat, + }, + cms: { + uploadImage, + imagePicker: ImagePicker, + imageInputField: ImageInputField, + }, + kanban: { + uploadImage, + imagePicker: ImagePicker, + resolveUser, + searchUsers, + // Wire comments into task detail dialogs + taskDetailBottomSlot: (task) => ( + + ), + }, + comments: { + defaultCommentPageSize: 5, + resourceLinks: { + "blog-post": (slug) => `/pages/blog/${slug}`, + }, + }, + }), + [uploadImage, uploadFileForChat], + ); + return ( ( - - ), - }, - aiChat: { - uploadFile: uploadFileForChat, - }, - cms: { - uploadImage, - imagePicker: ImagePicker, - imageInputField: ImageInputField, - }, - kanban: { - uploadImage, - imagePicker: ImagePicker, - resolveUser, - searchUsers, - // Wire comments into task detail dialogs - taskDetailBottomSlot: (task) => ( - - ), - }, - comments: { - defaultCommentPageSize: 5, - resourceLinks: { - "blog-post": (slug) => `/pages/blog/${slug}`, - }, - }, - }} + overrides={overrides} > - +
+ +
{/* Floating AI chat widget — visible on all /pages/* routes for route-aware AI context */}
diff --git a/scripts/codegen/files/tanstack/src/routes/pages/route.tsx b/scripts/codegen/files/tanstack/src/routes/pages/route.tsx index 0a4d25cc..980545ee 100644 --- a/scripts/codegen/files/tanstack/src/routes/pages/route.tsx +++ b/scripts/codegen/files/tanstack/src/routes/pages/route.tsx @@ -1,8 +1,8 @@ -import { StackProvider } from "@btst/stack/context"; +import { StackProvider, type ClientStackOverrides } from "@btst/stack/context"; import { createTanStackLayout, tanstackRouter } from "@btst/stack/tanstack"; import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ChatLayout } from "@btst/stack/plugins/ai-chat/client"; import { CommentThread } from "@btst/stack/plugins/comments/client/components"; import { @@ -31,6 +31,8 @@ export const Route = createFileRoute("/pages")({ }); function Layout() { + const [hydrated, setHydrated] = useState(false); + useEffect(() => setHydrated(true), []); const routeContext = Route.useRouteContext(); const { apiOrigin, initialIdentity, siteOrigin } = Route.useLoaderData(); const stack = useMemo( @@ -63,6 +65,51 @@ function Layout() { [], ); + const overrides = useMemo>( + () => ({ + // Only genuinely plugin-specific overrides remain — the shared + // router and resolved runtime come from the provider props above. + blog: { + uploadImage, + imagePicker: ImagePicker, + imageInputField: ImageInputField, + // Wire comments into the bottom of each blog post + postBottomSlot: (post) => ( + + ), + }, + aiChat: { + uploadFile: uploadFileForChat, + }, + cms: { + uploadImage, + imagePicker: ImagePicker, + imageInputField: ImageInputField, + }, + kanban: { + uploadImage, + imagePicker: ImagePicker, + resolveUser, + searchUsers, + // Wire comments into task detail dialogs + taskDetailBottomSlot: (task) => ( + + ), + }, + comments: { + defaultCommentPageSize: 5, + resourceLinks: { + "blog-post": (slug) => `/pages/blog/${slug}`, + }, + }, + }), + [uploadImage, uploadFileForChat], + ); + return ( @@ -71,49 +118,11 @@ function Layout() { router={tanstackRouter()} auth={clientAuth} initialIdentity={initialIdentity} - overrides={{ - // Only genuinely plugin-specific overrides remain — the shared - // router and resolved runtime come from the provider props above. - blog: { - uploadImage, - imagePicker: ImagePicker, - imageInputField: ImageInputField, - // Wire comments into the bottom of each blog post - postBottomSlot: (post) => ( - - ), - }, - aiChat: { - uploadFile: uploadFileForChat, - }, - cms: { - uploadImage, - imagePicker: ImagePicker, - imageInputField: ImageInputField, - }, - kanban: { - uploadImage, - imagePicker: ImagePicker, - resolveUser, - searchUsers, - // Wire comments into task detail dialogs - taskDetailBottomSlot: (task) => ( - - ), - }, - comments: { - defaultCommentPageSize: 5, - resourceLinks: { - "blog-post": (slug) => `/pages/blog/${slug}`, - }, - }, - }} + overrides={overrides} > - +
+ +
{/* Floating AI chat widget — visible on all /pages/* routes for route-aware AI context */}
Date: Wed, 16 Sep 2026 14:48:19 -0400 Subject: [PATCH 5/7] fix(cli): pair the preview with stable auth provider configuration --- .github/workflows/packed-consumers.yml | 2 +- packages/cli/scripts/test-better-auth-ui-fixtures.mjs | 2 +- packages/cli/src/utils/__tests__/package-installer.test.ts | 2 +- packages/cli/src/utils/__tests__/scaffold-plan.test.ts | 2 +- packages/cli/src/utils/constants.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/packed-consumers.yml b/.github/workflows/packed-consumers.yml index 81f0db3a..040d869a 100644 --- a/.github/workflows/packed-consumers.yml +++ b/.github/workflows/packed-consumers.yml @@ -63,7 +63,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: repository: better-stack-ai/better-auth-ui - ref: ee40b301daffaeb642678685ba7a78e02c2f03d4 # 2.0.1-rc.1 companion + ref: f6ac4591b3ec04df98130d97e727632e0a0f822b # 2.0.1-rc.2 companion path: .packed-consumer/better-auth-ui - name: Setup pnpm diff --git a/packages/cli/scripts/test-better-auth-ui-fixtures.mjs b/packages/cli/scripts/test-better-auth-ui-fixtures.mjs index aea62a26..cffcec6b 100644 --- a/packages/cli/scripts/test-better-auth-ui-fixtures.mjs +++ b/packages/cli/scripts/test-better-auth-ui-fixtures.mjs @@ -19,7 +19,7 @@ const SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url)); const CLI_DIRECTORY = resolve(SCRIPT_DIRECTORY, ".."); const REPOSITORY_ROOT = resolve(CLI_DIRECTORY, "../.."); const SHADCN_VERSION = "4.0.5"; -const BETTER_AUTH_UI_VERSION = "2.0.1-rc.1"; +const BETTER_AUTH_UI_VERSION = "2.0.1-rc.2"; const FRAMEWORKS = ["nextjs", "react-router", "tanstack"]; const AUTH_COHORT = Object.freeze({ diff --git a/packages/cli/src/utils/__tests__/package-installer.test.ts b/packages/cli/src/utils/__tests__/package-installer.test.ts index f4d23766..5ecca2a8 100644 --- a/packages/cli/src/utils/__tests__/package-installer.test.ts +++ b/packages/cli/src/utils/__tests__/package-installer.test.ts @@ -25,7 +25,7 @@ describe("installInitDependencies", () => { expect(installArguments).toContain("next-themes@0.4.6"); expect(installArguments).toContain("@btst/adapter-drizzle@2.2.3"); expect(installArguments).toContain("drizzle-orm@0.45.2"); - expect(installArguments).toContain("@btst/better-auth-ui@2.0.1-rc.1"); + expect(installArguments).toContain("@btst/better-auth-ui@2.0.1-rc.2"); expect(installArguments).toContain("better-auth@1.6.16"); expect(installArguments).toContain("@better-auth/core@1.6.16"); expect(installArguments).toContain("@better-auth/utils@0.4.1"); diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index c9ad82e8..6c5030cb 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -1053,7 +1053,7 @@ describe("scaffold plan", () => { expect(provider?.content).not.toContain("passkey:"); expect(plan.cssImports).toContain("@btst/better-auth-ui/css"); expect(plan.extraPackageVersions).toMatchObject({ - "@btst/better-auth-ui": "2.0.1-rc.1", + "@btst/better-auth-ui": "2.0.1-rc.2", "better-auth": "1.6.16", }); }, diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index 1079ffdf..6455c30f 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -206,7 +206,7 @@ export const PLUGINS: readonly PluginMeta[] = [ "@better-auth/passkey", ], extraInstallSpecs: [ - "@btst/better-auth-ui@2.0.1-rc.1", + "@btst/better-auth-ui@2.0.1-rc.2", "better-auth@1.6.16", "@better-auth/core@1.6.16", "@better-auth/utils@0.4.1", From 62fa86b2d695ad88b1a5887e5ef0d2728e7cc8ba Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:50:27 -0400 Subject: [PATCH 6/7] docs: explain typed provider overrides in the preview --- docs/content/docs/how-it-works.mdx | 10 ++++++++-- .../src/templates/nextjs/pages-client-layout.tsx.hbs | 2 ++ .../src/templates/react-router/pages-layout.tsx.hbs | 2 ++ .../cli/src/templates/tanstack/pages-layout.tsx.hbs | 2 ++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx index 84df78a1..cb2de39e 100644 --- a/docs/content/docs/how-it-works.mdx +++ b/docs/content/docs/how-it-works.mdx @@ -152,10 +152,16 @@ For values created inside a provider component, memoize them with their actual dependencies: ```tsx -const overrides = useMemo(() => ({ blog: { uploadImage } }), [uploadImage]) +import type { ClientStackOverrides } from "@btst/stack/context" + +const overrides = useMemo>( + () => ({ blog: { uploadImage } }), + [uploadImage], +) ``` -Pass `overrides={overrides}` to `StackProvider`. Module-level constants are also +`ClientStackOverrides` infers the available overrides from your resolved client +stack. Pass `overrides={overrides}` to `StackProvider`. Module-level constants are also appropriate for configuration that does not depend on component state. Changed identities, runtime values, and callbacks remain synchronous; memoization does not retain a previous permission decision or callback after its inputs change. diff --git a/packages/cli/src/templates/nextjs/pages-client-layout.tsx.hbs b/packages/cli/src/templates/nextjs/pages-client-layout.tsx.hbs index fcf8040b..7c7f89c7 100644 --- a/packages/cli/src/templates/nextjs/pages-client-layout.tsx.hbs +++ b/packages/cli/src/templates/nextjs/pages-client-layout.tsx.hbs @@ -1,7 +1,9 @@ "use client" import { StackProvider, useIdentity, type StackIdentity } from "@btst/stack/context" +{{#if pagesLayoutOverrides}} import type { ClientStackOverrides } from "@btst/stack/context" +{{/if}} import { nextRouter } from "@btst/stack/next" {{#if hasAiChat}} import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" diff --git a/packages/cli/src/templates/react-router/pages-layout.tsx.hbs b/packages/cli/src/templates/react-router/pages-layout.tsx.hbs index e1acb5d2..c2bdc858 100644 --- a/packages/cli/src/templates/react-router/pages-layout.tsx.hbs +++ b/packages/cli/src/templates/react-router/pages-layout.tsx.hbs @@ -1,5 +1,7 @@ import { StackProvider } from "@btst/stack/context" +{{#if pagesLayoutOverrides}} import type { ClientStackOverrides } from "@btst/stack/context" +{{/if}} import { reactRouter } from "@btst/stack/react-router" {{#if hasAiChat}} import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" diff --git a/packages/cli/src/templates/tanstack/pages-layout.tsx.hbs b/packages/cli/src/templates/tanstack/pages-layout.tsx.hbs index 494c369b..87e6efe5 100644 --- a/packages/cli/src/templates/tanstack/pages-layout.tsx.hbs +++ b/packages/cli/src/templates/tanstack/pages-layout.tsx.hbs @@ -1,6 +1,8 @@ import { createFileRoute, Outlet{{#if hasAiChat}}, useLocation{{/if}}{{#if hasBetterAuthUi}}, useRouter{{/if}} } from "@tanstack/react-router" import { StackProvider } from "@btst/stack/context" +{{#if pagesLayoutOverrides}} import type { ClientStackOverrides } from "@btst/stack/context" +{{/if}} import { tanstackRouter } from "@btst/stack/tanstack" {{#if hasAiChat}} import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" From c064e75233343b3796cc73f132dc614f7dff98e7 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:12:04 -0400 Subject: [PATCH 7/7] chore: prepare stable BTST 3.1.0 release --- .github/workflows/packed-consumers.yml | 2 +- .github/workflows/release.yml | 4 ++-- packages/cli/package.json | 2 +- packages/cli/scripts/test-better-auth-ui-fixtures.mjs | 2 +- packages/cli/src/utils/__tests__/package-installer.test.ts | 4 ++-- packages/cli/src/utils/__tests__/scaffold-plan.test.ts | 2 +- packages/cli/src/utils/constants.ts | 2 +- packages/cli/src/utils/package-installer.ts | 2 +- packages/stack/package.json | 2 +- 9 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/packed-consumers.yml b/.github/workflows/packed-consumers.yml index 040d869a..fa31a95c 100644 --- a/.github/workflows/packed-consumers.yml +++ b/.github/workflows/packed-consumers.yml @@ -63,7 +63,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: repository: better-stack-ai/better-auth-ui - ref: f6ac4591b3ec04df98130d97e727632e0a0f822b # 2.0.1-rc.2 companion + ref: f5f4f36fdeeb6060c7f95dce6706a3559bdbe5b7 # 2.0.1 companion path: .packed-consumer/better-auth-ui - name: Setup pnpm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ae502e5..f731cc09 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -177,13 +177,13 @@ jobs: local expected_version="$2" local published_version="" - for attempt in {1..12}; do + for attempt in {1..90}; do published_version=$(npm view "$package_spec" version 2>/dev/null || true) if [ "$published_version" = "$expected_version" ]; then printf '%s\n' "$published_version" return 0 fi - echo "Waiting for $package_spec to resolve to $expected_version (attempt $attempt/12)" >&2 + echo "Waiting for $package_spec to resolve to $expected_version (attempt $attempt/90)" >&2 sleep 10 done diff --git a/packages/cli/package.json b/packages/cli/package.json index 8a500cce..a5dcb2c5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@btst/codegen", - "version": "3.1.0-rc.1", + "version": "3.1.0", "description": "BTST project scaffolding and CLI passthrough commands.", "repository": { "type": "git", diff --git a/packages/cli/scripts/test-better-auth-ui-fixtures.mjs b/packages/cli/scripts/test-better-auth-ui-fixtures.mjs index cffcec6b..ca9cfa33 100644 --- a/packages/cli/scripts/test-better-auth-ui-fixtures.mjs +++ b/packages/cli/scripts/test-better-auth-ui-fixtures.mjs @@ -19,7 +19,7 @@ const SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url)); const CLI_DIRECTORY = resolve(SCRIPT_DIRECTORY, ".."); const REPOSITORY_ROOT = resolve(CLI_DIRECTORY, "../.."); const SHADCN_VERSION = "4.0.5"; -const BETTER_AUTH_UI_VERSION = "2.0.1-rc.2"; +const BETTER_AUTH_UI_VERSION = "2.0.1"; const FRAMEWORKS = ["nextjs", "react-router", "tanstack"]; const AUTH_COHORT = Object.freeze({ diff --git a/packages/cli/src/utils/__tests__/package-installer.test.ts b/packages/cli/src/utils/__tests__/package-installer.test.ts index 5ecca2a8..f3b26a32 100644 --- a/packages/cli/src/utils/__tests__/package-installer.test.ts +++ b/packages/cli/src/utils/__tests__/package-installer.test.ts @@ -21,11 +21,11 @@ describe("installInitDependencies", () => { }); const installArguments = execa.mock.calls[0]?.[1] as string[]; - expect(installArguments).toContain("@btst/stack@3.1.0-rc.1"); + expect(installArguments).toContain("@btst/stack@3.1.0"); expect(installArguments).toContain("next-themes@0.4.6"); expect(installArguments).toContain("@btst/adapter-drizzle@2.2.3"); expect(installArguments).toContain("drizzle-orm@0.45.2"); - expect(installArguments).toContain("@btst/better-auth-ui@2.0.1-rc.2"); + expect(installArguments).toContain("@btst/better-auth-ui@2.0.1"); expect(installArguments).toContain("better-auth@1.6.16"); expect(installArguments).toContain("@better-auth/core@1.6.16"); expect(installArguments).toContain("@better-auth/utils@0.4.1"); diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index 6c5030cb..f1166827 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -1053,7 +1053,7 @@ describe("scaffold plan", () => { expect(provider?.content).not.toContain("passkey:"); expect(plan.cssImports).toContain("@btst/better-auth-ui/css"); expect(plan.extraPackageVersions).toMatchObject({ - "@btst/better-auth-ui": "2.0.1-rc.2", + "@btst/better-auth-ui": "2.0.1", "better-auth": "1.6.16", }); }, diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index 6455c30f..5b67500c 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -206,7 +206,7 @@ export const PLUGINS: readonly PluginMeta[] = [ "@better-auth/passkey", ], extraInstallSpecs: [ - "@btst/better-auth-ui@2.0.1-rc.2", + "@btst/better-auth-ui@2.0.1", "better-auth@1.6.16", "@better-auth/core@1.6.16", "@better-auth/utils@0.4.1", diff --git a/packages/cli/src/utils/package-installer.ts b/packages/cli/src/utils/package-installer.ts index 9954cc14..5761aede 100644 --- a/packages/cli/src/utils/package-installer.ts +++ b/packages/cli/src/utils/package-installer.ts @@ -44,7 +44,7 @@ export async function installInitDependencies(input: { }); const packages = [ - "@btst/stack@3.1.0-rc.1", + "@btst/stack@3.1.0", "@btst/yar@1.3.2", "@tanstack/react-query@5.100.14", "next-themes@0.4.6", diff --git a/packages/stack/package.json b/packages/stack/package.json index 4008d690..beab920d 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,6 +1,6 @@ { "name": "@btst/stack", - "version": "3.1.0-rc.1", + "version": "3.1.0", "description": "A composable, plugin-based library for building full-stack applications.", "repository": { "type": "git",