Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/packed-consumers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: better-stack-ai/better-auth-ui
ref: f5f4f36fdeeb6060c7f95dce6706a3559bdbe5b7 # 2.0.1 companion
path: .packed-consumer/better-auth-ui

- name: Setup pnpm
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions docs/content/docs/how-it-works.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,29 @@ 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
import type { ClientStackOverrides } from "@btst/stack/context"

const overrides = useMemo<ClientStackOverrides<typeof clientStack>>(
() => ({ blog: { uploadImage } }),
[uploadImage],
)
```

`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.

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
Expand Down
12 changes: 9 additions & 3 deletions docs/content/docs/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -802,7 +804,7 @@ In order to use BTST, your application must meet the following requirements:
<StackProvider
stack={clientStack}
router={nextRouter()}
overrides={{ blog: { uploadImage } }}
overrides={overrides}
>
{children}
</StackProvider>
Expand Down Expand Up @@ -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)
}
Expand All @@ -861,7 +865,7 @@ In order to use BTST, your application must meet the following requirements:
<StackProvider
stack={clientStack}
router={reactRouter()}
overrides={{ blog: { uploadImage } }}
overrides={overrides}
>
<Outlet />
</StackProvider>
Expand All @@ -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,
Expand All @@ -900,7 +906,7 @@ In order to use BTST, your application must meet the following requirements:
<StackProvider
stack={clientStack}
router={tanstackRouter()}
overrides={{ blog: { uploadImage } }}
overrides={overrides}
>
<Outlet />
</StackProvider>
Expand Down
59 changes: 49 additions & 10 deletions docs/content/docs/plugins/blog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,48 @@ dehydrated protected data into a public page.
| `"newPost"` | — | *(nothing)* |
| `"editPost"` | `{ slug: string }` | Post to edit |

### Rendering prefetched pages

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 && <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 |
|---|---|---|
| `@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` |

```tsx
import { PostPage } from "@btst/stack/plugins/blog/client/pages/post"

<PostPage slug={slug} />
```

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

```tsx title="app/pages/blog/page.tsx"
Expand Down Expand Up @@ -738,14 +780,12 @@ export async function generateMetadata(): Promise<Metadata> {

export default async function BlogListPage() {
const queryClient = getOrCreateQueryClient()
const stackClient = getStackClient(queryClient)
const route = stackClient.router.getRoute(normalizePath(["blog"]))
if (!route) return null
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 (
<HydrationBoundary state={dehydrate(queryClient)}>
<route.PageComponent />
{route?.PageComponent && <route.PageComponent />}
</HydrationBoundary>
)
}
Expand All @@ -759,15 +799,14 @@ export async function generateStaticParams() {
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 })
const route = getStackClient(queryClient).router.getRoute(`/blog/${slug}`)
await myStack.raw.blog.prefetchForRoute("post", queryClient, { slug })
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<route.PageComponent />
{route?.PageComponent && <route.PageComponent />}
</HydrationBoundary>
)
}
Expand Down
148 changes: 148 additions & 0 deletions e2e/tests/page-loading.blog.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { expect, test } from "@playwright/test";
import { mockAuthHeaders } from "./helpers/mock-auth";

// 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", {
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();
let blockedPageChunk = false;
const errors: string[] = [];
const scripts: Promise<string>[] = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("response", (response) => {
if (response.request().resourceType() === "script") {
scripts.push(response.text());
}
});
await page.route("**/*.js", async (request) => {
await new Promise((resolve) => setTimeout(resolve, 750));
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,
contentHiddenAfterContent: false,
};
Object.assign(window, { blogLoadingState: state });
const visible = (selector: string) =>
Array.from(document.querySelectorAll(selector)).some(
(element) => element.getBoundingClientRect().height > 0,
);
function sample() {
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(
'[data-testid="posts-skeleton"], [data-testid="post-skeleton"]',
)
) {
state.skeletonAfterContent = true;
}
requestAnimationFrame(sample);
}
requestAnimationFrame(sample);
});
try {
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,
exact: true,
}),
).toBeVisible();
await expect(page.getByTestId("hydration-update")).toHaveAttribute(
"data-hydrated",
"true",
);
expect(
await page.evaluate(
() =>
(
window as unknown as {
blogLoadingState: {
sawContent: boolean;
skeletonAfterContent: boolean;
contentHiddenAfterContent: boolean;
};
}
).blogLoadingState,
),
).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.
for (const marker of [
"milkdown-custom",
"cms-list-search",
"task-detail-bottom-slot",
]) {
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(),
});
expect(deleted.ok()).toBeTruthy();
}
});
}
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@btst/codegen",
"version": "0.2.1",
"version": "3.1.0",
"description": "BTST project scaffolding and CLI passthrough commands.",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/scripts/test-better-auth-ui-fixtures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
const FRAMEWORKS = ["nextjs", "react-router", "tanstack"];

const AUTH_COHORT = Object.freeze({
Expand Down
18 changes: 13 additions & 5 deletions packages/cli/src/templates/nextjs/pages-client-layout.tsx.hbs
Original file line number Diff line number Diff line change
@@ -1,6 +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"
Expand Down Expand Up @@ -45,18 +48,23 @@ export function BtstPagesClientLayout({
const pathname = usePathname()
const showChatWidget = !pathname.startsWith("/pages/chat")
{{/if}}
{{#if pagesLayoutOverrides}}
const overrides = useMemo<ClientStackOverrides<typeof browserStack>>(
() => ({
{{{pagesLayoutOverrides}}}
}),
[{{#if hasBetterAuthUi}}authClient, frameworkRouter{{/if}}],
)
{{/if}}

return (
<QueryClientProvider client={queryClient}>
<StackProvider
stack={browserStack}
initialIdentity={initialIdentity}
router={nextRouter()}
{{#if pagesLayoutOverrides}}
overrides={
{
{{{pagesLayoutOverrides}}}
}
}
overrides={overrides}
{{/if}}
>
{{#if hasAiChat}}
Expand Down
Loading
Loading