diff --git a/docs/content/docs/installation.mdx b/docs/content/docs/installation.mdx index e197a4b3..d0174d04 100644 --- a/docs/content/docs/installation.mdx +++ b/docs/content/docs/installation.mdx @@ -6,6 +6,7 @@ description: Learn how to install and configure BTST in your project. import { Steps, Step } from "fumadocs-ui/components/steps"; import { Tabs, Tab } from "fumadocs-ui/components/tabs"; import { Callout } from "fumadocs-ui/components/callout"; +import { Accordions, Accordion } from "fumadocs-ui/components/accordion"; ## AI Agent Skills @@ -358,61 +359,39 @@ In order to use BTST, your application must meet the following requirements: ### Create API Route - Create a catch-all API route to handle BTST requests. The route will handle requests for the path `/api/data/*`. If you use a different path make sure to update the `basePath` in the `stack` config to match your chosen path. + Create a catch-all API route to handle BTST requests. The `toNextRouteHandlers` / `toReactRouterHandlers` / `toTanStackHandlers` helpers from the framework entry points wire your stack `handler` to every HTTP method the route needs. The route will handle requests for the path `/api/data/*`. If you use a different path make sure to update the `basePath` in the `stack` config to match your chosen path. ```ts title="app/api/data/[[...all]]/route.ts" + import { toNextRouteHandlers } from "@btst/stack/next" import { handler } from "@/lib/stack" - export const GET = handler - export const POST = handler - export const PUT = handler - export const PATCH = handler - export const DELETE = handler + export const { GET, POST, PUT, PATCH, DELETE } = toNextRouteHandlers(handler) ``` - ```ts title="app/routes/api/data/route.ts" + ```ts title="app/routes/api/data/$.ts" + import { toReactRouterHandlers } from "@btst/stack/react-router" import { handler } from "~/lib/stack" - import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node" - export async function loader({ request }: LoaderFunctionArgs) { - return handler(request) - } - - export async function action({ request }: ActionFunctionArgs) { - return handler(request) - } + // React Router's build can't strip destructured exports from route + // modules, so assign loader/action individually. + const handlers = toReactRouterHandlers(handler) + export const loader = handlers.loader + export const action = handlers.action ``` ```ts title="src/routes/api/data/$.ts" - import { createFileRoute } from '@tanstack/react-router' - import { handler } from '@/lib/stack' + import { createFileRoute } from "@tanstack/react-router" + import { toTanStackHandlers } from "@btst/stack/tanstack" + import { handler } from "@/lib/stack" - export const Route = createFileRoute('/api/data/$')({ - server: { - handlers: { - GET: async ({ request }) => { - return handler(request) - }, - POST: async ({ request }) => { - return handler(request) - }, - PUT: async ({ request }) => { - return handler(request) - }, - PATCH: async ({ request }) => { - return handler(request) - }, - DELETE: async ({ request }) => { - return handler(request) - }, - }, - }, + export const Route = createFileRoute("/api/data/$")({ + server: { handlers: toTanStackHandlers(handler) }, }) ``` @@ -455,6 +434,60 @@ In order to use BTST, your application must meet the following requirements: ``` + + + + The helpers only wire your `handler` to each HTTP method — hand-writing the glue remains fully supported if you need custom behavior (auth wrappers, logging, filtering methods): + + + + ```ts title="app/api/data/[[...all]]/route.ts" + import { handler } from "@/lib/stack" + + export const GET = handler + export const POST = handler + export const PUT = handler + export const PATCH = handler + export const DELETE = handler + ``` + + + + ```ts title="app/routes/api/data/$.ts" + import type { Route } from "./+types/$" + import { handler } from "~/lib/stack" + + export function loader({ request }: Route.LoaderArgs) { + return handler(request) + } + + export function action({ request }: Route.ActionArgs) { + return handler(request) + } + ``` + + + + ```ts title="src/routes/api/data/$.ts" + import { createFileRoute } from '@tanstack/react-router' + import { handler } from '@/lib/stack' + + export const Route = createFileRoute('/api/data/$')({ + server: { + handlers: { + GET: async ({ request }) => handler(request), + POST: async ({ request }) => handler(request), + PUT: async ({ request }) => handler(request), + PATCH: async ({ request }) => handler(request), + DELETE: async ({ request }) => handler(request), + }, + }, + }) + ``` + + + + @@ -782,162 +815,213 @@ In order to use BTST, your application must meet the following requirements: ### Set Up Page Handler - Create a catch-all route to handle BTST pages defined in your plugins. This enables server-side rendering, metadata generation and automatic route handling. + Create a catch-all route to handle BTST pages defined in your plugins. The page factories from the framework entry points own the invariant plumbing once: server-side prefetching via `route.loader()`, React Query dehydration (including failed queries, so the client doesn't refetch on errors), loader-before-meta ordering for SEO metadata, and 404 handling via your framework's mechanism. ```tsx title="app/pages/[[...all]]/page.tsx" - import { dehydrate, HydrationBoundary } from "@tanstack/react-query" - import { notFound } from "next/navigation" + import { createNextPage } from "@btst/stack/next" import { getOrCreateQueryClient } from "@/lib/query-client" import { getStackClient } from "@/lib/stack-client" - import { metaElementsToObject, normalizePath } from "@btst/stack/client" - import { Metadata } from "next" - export default async function Page({ params }: { params: Promise<{ all: string[] }> }) { - const pathParams = await params - const path = normalizePath(pathParams?.all) - - const queryClient = getOrCreateQueryClient() - const stackClient = getStackClient(queryClient) - const route = stackClient.router.getRoute(path) - - // Prefetch data server-side if the route has a loader - if (route?.loader) await route.loader() - - // Serialize React Query cache for client hydration - const dehydratedState = dehydrate(queryClient) - - return ( - - {route && route.PageComponent ? : notFound()} - - ) - } + export const dynamic = "force-dynamic" - export async function generateMetadata({ params }: { params: Promise<{ all: string[] }> }) { - const pathParams = await params - const path = normalizePath(pathParams?.all) - - const queryClient = getOrCreateQueryClient() - const stackClient = getStackClient(queryClient) - const route = stackClient.router.getRoute(path) - - if (!route) return notFound() - if (route?.loader) await route.loader() - - // Convert plugin meta elements to Next.js Metadata format - return route.meta ? metaElementsToObject(route.meta()) satisfies Metadata : { title: "No meta" } - } + const page = createNextPage({ getStackClient, getQueryClient: getOrCreateQueryClient }) + export default page.Page + export const generateMetadata = page.generateMetadata ``` - ```tsx title="app/routes/pages/index.tsx" - import type { Route } from "./+types/index" - import { useLoaderData } from "react-router" - import { dehydrate, HydrationBoundary, QueryClient, useQueryClient } from "@tanstack/react-query" + ```tsx title="app/routes/pages/$.tsx" + import { createReactRouterPage } from "@btst/stack/react-router" + import { getOrCreateQueryClient } from "~/lib/query-client" import { getStackClient } from "~/lib/stack-client" - import { normalizePath } from "@btst/stack/client" - - export async function loader({ params }: Route.LoaderArgs) { - const path = normalizePath(params["*"]) - - // Create QueryClient for this request with consistent config - const queryClient = new QueryClient({ - defaultOptions: { queries: { staleTime: 1000 * 60 * 5, refetchOnMount: false, retry: false } } - }) - const stackClient = getStackClient(queryClient) - const route = stackClient.router.getRoute(path) - - if (route?.loader) await route.loader() - - // Include errors so client doesn't refetch on error - const dehydratedState = dehydrate(queryClient) - - return { path, dehydratedState, meta: route?.meta?.() } - } - export function meta({ loaderData }: Route.MetaArgs) { - return loaderData.meta - } - - export default function PagesIndex() { - const { path, dehydratedState } = useLoaderData() - const queryClient = useQueryClient() - const route = getStackClient(queryClient).router.getRoute(path) - const Page = route && route.PageComponent ? :
Route not found
- - return dehydratedState ? ( - {Page} - ) : Page - } + const page = createReactRouterPage({ getStackClient, getQueryClient: getOrCreateQueryClient }) + export const loader = page.loader + export const meta = page.meta + export const ErrorBoundary = page.ErrorBoundary + export default page.Component ```
```tsx title="src/routes/pages/$.tsx" - import { createFileRoute, notFound } from "@tanstack/react-router" + import { createFileRoute } from "@tanstack/react-router" + import { createTanStackPageOptions } from "@btst/stack/tanstack" import { getStackClient } from "@/lib/stack-client" - import { normalizePath } from "@btst/stack/client" - - export const Route = createFileRoute("/pages/$")({ - ssr: true, - component: Page, - loader: async ({ params, context }) => { - const routePath = normalizePath(params._splat) - const stackClient = getStackClient(context.queryClient) - const route = stackClient.router.getRoute(routePath) - - if (!route) throw notFound() - if (route?.loader) await route.loader() - - return { meta: await route?.meta?.() } - }, - head: ({ loaderData }) => { - return loaderData?.meta && Array.isArray(loaderData.meta) - ? { meta: loaderData.meta } - : { meta: [{ title: "No Meta" }], title: "No Meta" } - }, - notFoundComponent: () =>

This page doesn't exist!

- }) - function Page() { - const context = Route.useRouteContext() - const { _splat } = Route.useParams() - const routePath = normalizePath(_splat) - const route = getStackClient(context.queryClient).router.getRoute(routePath) - - return route && route.PageComponent ? :
Route not found
- } + export const Route = createFileRoute("/pages/$")( + createTanStackPageOptions({ getStackClient }), + ) ```
- - **How it works:** - - `stackClient.router.getRoute(path)` matches the URL to a plugin route and returns a route object: - - ```typescript - route = { - PageComponent: React.ComponentType, // The page to render - loader?: () => Promise, // Prefetches React Query data - meta?: () => MetadataElements, // Returns SEO metadata - ErrorComponent?: React.ComponentType, // Standalone error components - LoadingComponent?: React.ComponentType // Standalone loading components - } - ``` - - **Key steps:** - - **Server-side data loading**: Call `route.loader()` before rendering to prefetch data into React Query cache - - **Hydration**: Use `dehydrate()` to serialize prefetched data for the client (not required with TanStack Start) - - **Error handling**: Configure your query client with `shouldDehydrateQuery` to include failed queries in dehydration, preventing client-side refetching on errors - - **Metadata generation**: Use `route.meta()` with framework-specific meta functions for SEO - - **404 handling**: Return `notFound()` or your framework's equivalent function when routes don't exist + **How it works:** + + The factory matches the URL to a plugin route via `stackClient.router.getRoute(path)`, prefetches data server-side with `route.loader()`, renders the route's `PageComponent` with instant hydration on the client, and generates SEO metadata from `route.meta()` (running the loader first, so meta can read prefetched data). + + **Escape hatches:** + - `createNextPage` accepts `notFound` (replaces `notFound()` from `next/navigation`), `wrapPage`, and `dehydrateOptions` + - `createReactRouterPage` accepts `NotFound` (component rendered when no route matches), `ErrorBoundary` (production-safe error UI), `wrapPage`, and `dehydrateOptions` + - `createTanStackPageOptions` accepts `getQueryClient` (defaults to the router context's `queryClient`); spread extra route options like `notFoundComponent` alongside it + - Hand-writing the route file remains fully supported — see below + + + `stackClient.router.getRoute(path)` matches the URL to a plugin route and returns a route object: + + ```typescript + route = { + PageComponent: React.ComponentType, // The page to render + loader?: () => Promise, // Prefetches React Query data + meta?: () => MetadataElements, // Returns SEO metadata + ErrorComponent?: React.ComponentType, // Standalone error components + LoadingComponent?: React.ComponentType // Standalone loading components + } + ``` + + If you need behavior the factories don't cover, write the catch-all route yourself: + + + + ```tsx title="app/pages/[[...all]]/page.tsx" + import { dehydrate, HydrationBoundary } from "@tanstack/react-query" + import { notFound } from "next/navigation" + import { getOrCreateQueryClient } from "@/lib/query-client" + import { getStackClient } from "@/lib/stack-client" + import { metaElementsToObject, normalizePath } from "@btst/stack/client" + import { Metadata } from "next" + + export default async function Page({ params }: { params: Promise<{ all: string[] }> }) { + const pathParams = await params + const path = normalizePath(pathParams?.all) + + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(path) + + // Prefetch data server-side if the route has a loader + if (route?.loader) await route.loader() + + // Serialize React Query cache for client hydration + const dehydratedState = dehydrate(queryClient) + + return ( + + {route && route.PageComponent ? : notFound()} + + ) + } + + export async function generateMetadata({ params }: { params: Promise<{ all: string[] }> }) { + const pathParams = await params + const path = normalizePath(pathParams?.all) + + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(path) + + if (!route) return notFound() + if (route?.loader) await route.loader() + + // Convert plugin meta elements to Next.js Metadata format + return route.meta ? metaElementsToObject(route.meta()) satisfies Metadata : { title: "No meta" } + } + ``` + + + + ```tsx title="app/routes/pages/$.tsx" + import type { Route } from "./+types/$" + import { useLoaderData } from "react-router" + import { dehydrate, HydrationBoundary, useQueryClient } from "@tanstack/react-query" + import { getOrCreateQueryClient } from "~/lib/query-client" + import { getStackClient } from "~/lib/stack-client" + import { normalizePath } from "@btst/stack/client" + + export async function loader({ params }: Route.LoaderArgs) { + const queryClient = getOrCreateQueryClient() + const path = normalizePath(params["*"]) + const route = getStackClient(queryClient).router.getRoute(path) + + if (route?.loader) await route.loader() + + // Include errors so client doesn't refetch on error + const dehydratedState = dehydrate(queryClient) + + return { path, dehydratedState, meta: route?.meta?.() } + } + + export function meta({ loaderData }: Route.MetaArgs) { + return loaderData.meta + } + + export default function PagesIndex() { + const { path, dehydratedState } = useLoaderData() + const queryClient = useQueryClient() + const route = getStackClient(queryClient).router.getRoute(path) + const Page = route && route.PageComponent ? :
Route not found
+ + return dehydratedState ? ( + {Page} + ) : Page + } + ``` +
+ + + ```tsx title="src/routes/pages/$.tsx" + import { createFileRoute, notFound } from "@tanstack/react-router" + import { getStackClient } from "@/lib/stack-client" + import { normalizePath } from "@btst/stack/client" + + export const Route = createFileRoute("/pages/$")({ + ssr: true, + component: Page, + loader: async ({ params, context }) => { + const routePath = normalizePath(params._splat) + const stackClient = getStackClient(context.queryClient) + const route = stackClient.router.getRoute(routePath) + + if (!route) throw notFound() + if (route?.loader) await route.loader() + + return { meta: await route?.meta?.() } + }, + head: ({ loaderData }) => { + return loaderData?.meta && Array.isArray(loaderData.meta) + ? { meta: loaderData.meta } + : { meta: [{ title: "No Meta" }], title: "No Meta" } + }, + notFoundComponent: () =>

This page doesn't exist!

+ }) + + function Page() { + const context = Route.useRouteContext() + const { _splat } = Route.useParams() + const routePath = normalizePath(_splat) + const route = getStackClient(context.queryClient).router.getRoute(routePath) + + return route && route.PageComponent ? :
Route not found
+ } + ``` +
+
+ + **Key steps to get right by hand:** + - **Server-side data loading**: Call `route.loader()` before rendering to prefetch data into React Query cache + - **Hydration**: Use `dehydrate()` to serialize prefetched data for the client (not required with TanStack Start) + - **Error handling**: Configure your query client with `shouldDehydrateQuery` to include failed queries in dehydration, preventing client-side refetching on errors + - **Metadata generation**: Use `route.meta()` with framework-specific meta functions for SEO + - **404 handling**: Return `notFound()` or your framework's equivalent function when routes don't exist +
+
+
diff --git a/packages/cli/src/templates/nextjs/api-route.ts.hbs b/packages/cli/src/templates/nextjs/api-route.ts.hbs index de220aca..7f535e30 100644 --- a/packages/cli/src/templates/nextjs/api-route.ts.hbs +++ b/packages/cli/src/templates/nextjs/api-route.ts.hbs @@ -1,7 +1,4 @@ +import { toNextRouteHandlers } from "@btst/stack/next" import { handler } from "{{alias}}lib/stack" -export const GET = handler -export const POST = handler -export const PUT = handler -export const PATCH = handler -export const DELETE = handler +export const { GET, POST, PUT, PATCH, DELETE } = toNextRouteHandlers(handler) diff --git a/packages/cli/src/templates/nextjs/pages-route.tsx.hbs b/packages/cli/src/templates/nextjs/pages-route.tsx.hbs index 638c69bc..6fc4b56d 100644 --- a/packages/cli/src/templates/nextjs/pages-route.tsx.hbs +++ b/packages/cli/src/templates/nextjs/pages-route.tsx.hbs @@ -1,49 +1,9 @@ -import { dehydrate, HydrationBoundary } from "@tanstack/react-query" -import { normalizePath, metaElementsToObject } from "@btst/stack/client" -import { notFound } from "next/navigation" +import { createNextPage } from "@btst/stack/next" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" import { getStackClient } from "{{alias}}lib/stack-client" -import type { Metadata } from "next" export const dynamic = "force-dynamic" -export default async function BtstPagesRoute({ - params, -}: { - params: Promise<{ all?: string[] }> -}) { - const pathParams = await params - const path = normalizePath(pathParams?.all) - const queryClient = getOrCreateQueryClient() - const stackClient = getStackClient(queryClient) - const route = stackClient.router.getRoute(path) - - if (route?.loader) { - await route.loader() - } - - return ( - - {route?.PageComponent ? : notFound()} - - ) -} - -export async function generateMetadata({ - params, -}: { - params: Promise<{ all?: string[] }> -}): Promise { - const pathParams = await params - const path = normalizePath(pathParams?.all) - const queryClient = getOrCreateQueryClient() - const stackClient = getStackClient(queryClient) - const route = stackClient.router.getRoute(path) - if (!route?.meta) { - return {} - } - if (route?.loader) { - await route.loader() - } - return metaElementsToObject(route.meta()) satisfies Metadata -} +const page = createNextPage({ getStackClient, getQueryClient: getOrCreateQueryClient }) +export default page.Page +export const generateMetadata = page.generateMetadata diff --git a/packages/cli/src/templates/react-router/api-route.ts.hbs b/packages/cli/src/templates/react-router/api-route.ts.hbs index 09bc3c5e..588bb6a3 100644 --- a/packages/cli/src/templates/react-router/api-route.ts.hbs +++ b/packages/cli/src/templates/react-router/api-route.ts.hbs @@ -1,10 +1,8 @@ -import type { Route } from "./+types/$" +import { toReactRouterHandlers } from "@btst/stack/react-router" import { handler } from "{{alias}}lib/stack" -export function loader({ request }: Route.LoaderArgs) { - return handler(request) -} - -export function action({ request }: Route.ActionArgs) { - return handler(request) -} +// React Router's build can't strip destructured exports from route modules, +// so assign loader/action individually instead of destructuring the export. +const handlers = toReactRouterHandlers(handler) +export const loader = handlers.loader +export const action = handlers.action diff --git a/packages/cli/src/templates/react-router/pages-route.tsx.hbs b/packages/cli/src/templates/react-router/pages-route.tsx.hbs index f0e562d3..bc46b43b 100644 --- a/packages/cli/src/templates/react-router/pages-route.tsx.hbs +++ b/packages/cli/src/templates/react-router/pages-route.tsx.hbs @@ -1,44 +1,9 @@ -import type { Route } from "./+types/$" -import { useLoaderData, useRouteError } from "react-router" -import { dehydrate, HydrationBoundary, useQueryClient } from "@tanstack/react-query" -import { normalizePath } from "@btst/stack/client" +import { createReactRouterPage } from "@btst/stack/react-router" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" import { getStackClient } from "{{alias}}lib/stack-client" -export async function loader({ params }: Route.LoaderArgs) { - const queryClient = getOrCreateQueryClient() - const path = normalizePath(params["*"]) - const route = getStackClient(queryClient).router.getRoute(path) - - if (route?.loader) { - await route.loader() - } - - return { - path, - dehydratedState: dehydrate(queryClient), - meta: route?.meta?.(), - } -} - -export function meta({ loaderData }: Route.MetaArgs) { - return loaderData.meta -} - -export default function BtstPagesRoute() { - const data = useLoaderData() - const queryClient = useQueryClient() - const route = getStackClient(queryClient).router.getRoute(data.path) - const page = route?.PageComponent ? :
Route not found
- - return ( - - {page} - - ) -} - -export function ErrorBoundary() { - const error = useRouteError() - return
{String(error)}
-} +const page = createReactRouterPage({ getStackClient, getQueryClient: getOrCreateQueryClient }) +export const loader = page.loader +export const meta = page.meta +export const ErrorBoundary = page.ErrorBoundary +export default page.Component diff --git a/packages/cli/src/templates/tanstack/api-route.ts.hbs b/packages/cli/src/templates/tanstack/api-route.ts.hbs index 79aeedf1..4d6307c2 100644 --- a/packages/cli/src/templates/tanstack/api-route.ts.hbs +++ b/packages/cli/src/templates/tanstack/api-route.ts.hbs @@ -1,14 +1,7 @@ import { createFileRoute } from "@tanstack/react-router" +import { toTanStackHandlers } from "@btst/stack/tanstack" import { handler } from "{{alias}}lib/stack" export const Route = createFileRoute("/api/data/$")({ - server: { - handlers: { - GET: async ({ request }) => handler(request), - POST: async ({ request }) => handler(request), - PUT: async ({ request }) => handler(request), - PATCH: async ({ request }) => handler(request), - DELETE: async ({ request }) => handler(request), - }, - }, + server: { handlers: toTanStackHandlers(handler) }, }) diff --git a/packages/cli/src/templates/tanstack/pages-route.tsx.hbs b/packages/cli/src/templates/tanstack/pages-route.tsx.hbs index 85a0c6e3..fdd96757 100644 --- a/packages/cli/src/templates/tanstack/pages-route.tsx.hbs +++ b/packages/cli/src/templates/tanstack/pages-route.tsx.hbs @@ -1,31 +1,8 @@ -import { createFileRoute, notFound } from "@tanstack/react-router" -import { normalizePath } from "@btst/stack/client" -import { getStackClient } from "{{alias}}lib/stack-client" +import { createFileRoute } from "@tanstack/react-router" +import { createTanStackPageOptions } from "@btst/stack/tanstack" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" +import { getStackClient } from "{{alias}}lib/stack-client" -export const Route = createFileRoute("/pages/$")({ - ssr: true, - component: BtstPagesRoute, - loader: async ({ params }) => { - const queryClient = getOrCreateQueryClient() - const routePath = normalizePath(params._splat) - const route = getStackClient(queryClient).router.getRoute(routePath) - if (!route) throw notFound() - if (route.loader) await route.loader() - return { meta: route.meta?.() } - }, - head: ({ loaderData }) => { - if (!loaderData?.meta || !Array.isArray(loaderData.meta)) { - return { title: "No Meta", meta: [{ title: "No Meta" }] } - } - return { meta: loaderData.meta } - }, -}) - -function BtstPagesRoute() { - const params = Route.useParams() - const queryClient = getOrCreateQueryClient() - const routePath = normalizePath(params._splat) - const route = getStackClient(queryClient).router.getRoute(routePath) - return route?.PageComponent ? :
Route not found
-} +export const Route = createFileRoute("/pages/$")( + createTanStackPageOptions({ getStackClient, getQueryClient: getOrCreateQueryClient }), +) diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index 43d7693c..2694a876 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -315,7 +315,7 @@ describe("scaffold plan", () => { 'import { getOrCreateQueryClient } from "@/lib/query-client"', ); expect(pagesRouteFile?.content).toContain( - "const queryClient = getOrCreateQueryClient()", + "getQueryClient: getOrCreateQueryClient", ); expect(pagesRouteFile?.content).not.toContain("new QueryClient()"); }); @@ -337,7 +337,7 @@ describe("scaffold plan", () => { 'import { getOrCreateQueryClient } from "@/lib/query-client"', ); expect(pagesRouteFile?.content).toContain( - "const queryClient = getOrCreateQueryClient()", + "getQueryClient: getOrCreateQueryClient", ); expect(pagesRouteFile?.content).not.toContain("context.queryClient"); }); @@ -740,7 +740,7 @@ describe("scaffold plan", () => { (f) => f.path === "app/pages/[[...all]]/page.tsx", ); expect(pagesRoute?.content).toContain("generateMetadata"); - expect(pagesRoute?.content).toContain("metaElementsToObject"); + expect(pagesRoute?.content).toContain("createNextPage"); }); it("emits SSG pages for nextjs when blog selected", async () => { diff --git a/packages/stack/src/__tests__/entry-factories.test.tsx b/packages/stack/src/__tests__/entry-factories.test.tsx new file mode 100644 index 00000000..21e1875b --- /dev/null +++ b/packages/stack/src/__tests__/entry-factories.test.tsx @@ -0,0 +1,300 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToString } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { createNextPage, toNextRouteHandlers } from "../next"; +import { createReactRouterPage, toReactRouterHandlers } from "../react-router"; +import type { + StackClientLike, + StackRouteLike, +} from "../shared/entry-factories"; +import { createTanStackPageOptions, toTanStackHandlers } from "../tanstack"; + +function makeStackClient(routes: Record) { + return (_queryClient: QueryClient): StackClientLike => ({ + router: { + getRoute: (path: string) => routes[path] ?? null, + }, + }); +} + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +const okHandler = async (request: Request) => + new Response(`ok:${request.method}`); + +describe("API route handler factories", () => { + it("toNextRouteHandlers exposes the handler for all five methods", async () => { + const handlers = toNextRouteHandlers(okHandler); + expect(Object.keys(handlers)).toEqual([ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + ]); + const res = await handlers.POST( + new Request("http://test.local/api/data", { method: "POST" }), + ); + expect(await res.text()).toBe("ok:POST"); + }); + + it("toReactRouterHandlers delegates loader and action to the handler", async () => { + const { loader, action } = toReactRouterHandlers(okHandler); + const getRes = await loader({ + request: new Request("http://test.local/api/data"), + }); + expect(await getRes.text()).toBe("ok:GET"); + const postRes = await action({ + request: new Request("http://test.local/api/data", { method: "POST" }), + }); + expect(await postRes.text()).toBe("ok:POST"); + }); + + it("toTanStackHandlers delegates all five methods to the handler", async () => { + const handlers = toTanStackHandlers(okHandler); + expect(Object.keys(handlers)).toEqual([ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + ]); + const res = await handlers.DELETE({ + request: new Request("http://test.local/api/data", { method: "DELETE" }), + }); + expect(await res.text()).toBe("ok:DELETE"); + }); +}); + +describe("createNextPage", () => { + const params = (all?: string[]) => Promise.resolve({ all }); + + it("runs the loader and renders the page inside a HydrationBoundary", async () => { + const calls: string[] = []; + const queryClient = makeQueryClient(); + const page = createNextPage({ + getStackClient: makeStackClient({ + "/blog": { + PageComponent: () =>
blog page
, + loader: async () => { + calls.push("loader"); + }, + }, + }), + getQueryClient: () => queryClient, + }); + + const element = await page.Page({ params: params(["blog"]) }); + const html = renderToString( + {element}, + ); + expect(calls).toEqual(["loader"]); + expect(html).toContain("blog page"); + }); + + it("calls notFound when no route matches", async () => { + const notFound = vi.fn(() => { + throw new Error("NEXT_NOT_FOUND_TEST"); + }); + const page = createNextPage({ + getStackClient: makeStackClient({}), + getQueryClient: makeQueryClient, + notFound: notFound as unknown as () => never, + }); + + await expect(page.Page({ params: params(["missing"]) })).rejects.toThrow( + "NEXT_NOT_FOUND_TEST", + ); + expect(notFound).toHaveBeenCalledOnce(); + }); + + it("generateMetadata runs the loader before meta and converts elements", async () => { + const calls: string[] = []; + const page = createNextPage({ + getStackClient: makeStackClient({ + "/blog": { + loader: async () => { + calls.push("loader"); + }, + meta: () => { + calls.push("meta"); + return [ + { name: "title", content: "Blog Title" }, + { name: "description", content: "Blog Description" }, + ]; + }, + }, + }), + getQueryClient: makeQueryClient, + }); + + const metadata = await page.generateMetadata({ params: params(["blog"]) }); + expect(calls).toEqual(["loader", "meta"]); + expect(metadata).toMatchObject({ + title: "Blog Title", + description: "Blog Description", + }); + }); + + it("generateMetadata returns {} without running the loader when the route has no meta", async () => { + const loader = vi.fn(); + const page = createNextPage({ + getStackClient: makeStackClient({ "/blog": { loader } }), + getQueryClient: makeQueryClient, + }); + + const metadata = await page.generateMetadata({ params: params(["blog"]) }); + expect(metadata).toEqual({}); + expect(loader).not.toHaveBeenCalled(); + }); + + it("generateMetadata calls notFound when no route matches", async () => { + const notFound = vi.fn(() => { + throw new Error("NEXT_NOT_FOUND_TEST"); + }); + const page = createNextPage({ + getStackClient: makeStackClient({}), + getQueryClient: makeQueryClient, + notFound: notFound as unknown as () => never, + }); + + await expect( + page.generateMetadata({ params: params(["missing"]) }), + ).rejects.toThrow("NEXT_NOT_FOUND_TEST"); + expect(notFound).toHaveBeenCalledOnce(); + }); +}); + +describe("createReactRouterPage", () => { + it("loader prefetches and returns path, dehydratedState and meta", async () => { + const queryClient = makeQueryClient(); + const page = createReactRouterPage({ + getStackClient: makeStackClient({ + "/blog": { + loader: async () => { + await queryClient.prefetchQuery({ + queryKey: ["post"], + queryFn: async () => "post data", + }); + }, + meta: () => [{ name: "title", content: "Blog" }], + }, + }), + getQueryClient: () => queryClient, + }); + + const data = await page.loader({ params: { "*": "blog" } }); + expect(data.path).toBe("/blog"); + expect(data.meta).toEqual([{ name: "title", content: "Blog" }]); + expect(data.dehydratedState.queries).toHaveLength(1); + + // meta() maps loader data through (supports both arg field names) + expect(page.meta({ loaderData: data })).toEqual(data.meta); + expect(page.meta({ data })).toEqual(data.meta); + }); + + it("dehydrates failed queries so the client does not refetch on error", async () => { + const queryClient = makeQueryClient(); + const page = createReactRouterPage({ + getStackClient: makeStackClient({ + "/broken": { + loader: async () => { + await queryClient.prefetchQuery({ + queryKey: ["broken"], + queryFn: async () => { + throw new Error("boom"); + }, + }); + }, + }, + }), + getQueryClient: () => queryClient, + }); + + const data = await page.loader({ params: { "*": "broken" } }); + const errored = data.dehydratedState.queries.find( + (q) => q.state.status === "error", + ); + expect(errored).toBeDefined(); + }); + + it("supports custom ErrorBoundary and dehydrateOptions overrides", async () => { + const queryClient = makeQueryClient(); + const CustomErrorBoundary = () =>
custom error
; + const page = createReactRouterPage({ + getStackClient: makeStackClient({ + "/broken": { + loader: async () => { + await queryClient.prefetchQuery({ + queryKey: ["broken"], + queryFn: async () => { + throw new Error("boom"); + }, + }); + }, + }, + }), + getQueryClient: () => queryClient, + ErrorBoundary: CustomErrorBoundary, + dehydrateOptions: { shouldDehydrateQuery: () => false }, + }); + + expect(page.ErrorBoundary).toBe(CustomErrorBoundary); + const data = await page.loader({ params: { "*": "broken" } }); + expect(data.dehydratedState.queries).toHaveLength(0); + }); +}); + +describe("createTanStackPageOptions", () => { + it("loader throws notFound() when no route matches", async () => { + const options = createTanStackPageOptions({ + getStackClient: makeStackClient({}), + getQueryClient: makeQueryClient, + }); + + await expect( + options.loader({ params: { _splat: "missing" } }), + ).rejects.toMatchObject({ isNotFound: true }); + }); + + it("loader uses the router context queryClient, runs route.loader and returns meta", async () => { + const contextQueryClient = makeQueryClient(); + const seen: QueryClient[] = []; + const options = createTanStackPageOptions({ + getStackClient: (queryClient) => { + seen.push(queryClient); + return makeStackClient({ + "/blog": { + loader: async () => {}, + meta: () => [{ title: "Blog" }], + }, + })(queryClient); + }, + }); + + const data = await options.loader({ + params: { _splat: "blog" }, + context: { queryClient: contextQueryClient }, + }); + expect(seen).toEqual([contextQueryClient]); + expect(data).toEqual({ meta: [{ title: "Blog" }] }); + }); + + it("head falls back when there is no meta and passes meta through otherwise", () => { + const options = createTanStackPageOptions({ + getStackClient: makeStackClient({}), + getQueryClient: makeQueryClient, + }); + + expect(options.head({ loaderData: undefined })).toEqual({ + title: "No Meta", + meta: [{ title: "No Meta" }], + }); + const meta = [{ title: "Blog" }]; + expect(options.head({ loaderData: { meta } })).toEqual({ meta }); + }); +}); diff --git a/packages/stack/src/next/handlers.ts b/packages/stack/src/next/handlers.ts new file mode 100644 index 00000000..67cdb676 --- /dev/null +++ b/packages/stack/src/next/handlers.ts @@ -0,0 +1,24 @@ +import type { StackRequestHandler } from "../shared/entry-factories"; + +/** + * Wires a BTST API handler to the five HTTP method exports a Next.js route + * handler file needs. + * + * @example + * ```ts + * // app/api/data/[[...all]]/route.ts + * import { toNextRouteHandlers } from "@btst/stack/next"; + * import { handler } from "@/lib/stack"; + * + * export const { GET, POST, PUT, PATCH, DELETE } = toNextRouteHandlers(handler); + * ``` + */ +export function toNextRouteHandlers(handler: StackRequestHandler) { + return { + GET: handler, + POST: handler, + PUT: handler, + PATCH: handler, + DELETE: handler, + }; +} diff --git a/packages/stack/src/next/index.tsx b/packages/stack/src/next/index.tsx index 2d4af4c3..a01b07f9 100644 --- a/packages/stack/src/next/index.tsx +++ b/packages/stack/src/next/index.tsx @@ -1,102 +1,11 @@ -"use client"; -import NextImage from "next/image"; -import NextLink from "next/link"; -import { useRouter } from "next/navigation"; -import { useMemo } from "react"; -import type { StackRouter, StackRouterConfig } from "../context/router"; - -function NextLinkWrapper({ - href, - ...props -}: React.ComponentProps<"a"> & Record) { - return ; -} - -/** - * Next.js Image wrapper for plugins. - * Handles both cases: with explicit dimensions or using fill mode. - */ -function NextImageWrapper(props: React.ImgHTMLAttributes) { - const { alt = "", src = "", width, height, ...rest } = props; - - // Use fill mode if width or height are not provided - if (!width || !height) { - return ( - - - - ); - } - - return ( - - ); -} - -// Reads window.location.search instead of Next's useSearchParams() hook to -// avoid forcing a Suspense/CSR bailout during static generation. Returns -// empty params on the server. -function getSearchParams(): URLSearchParams { - return new URLSearchParams( - typeof window !== "undefined" ? window.location.search : "", - ); -} - -function useNextStackRouter(): StackRouter { - const router = useRouter(); - - return useMemo( - () => ({ - navigate: (path: string) => { - router.push(path); - }, - refresh: () => { - router.refresh(); - }, - setSearchParams: ( - next: URLSearchParams, - opts?: { replace?: boolean }, - ) => { - const query = next.toString(); - const path = `${window.location.pathname}${query ? `?${query}` : ""}`; - if (opts?.replace) { - router.replace(path); - } else { - router.push(path); - } - }, - }), - [router], - ); -} - -/** - * Router preset for Next.js (App Router). - * - * @example - * ```tsx - * import { nextRouter } from "@btst/stack/next"; - * - * - * ``` - */ -export function nextRouter(): StackRouterConfig { - return { - Link: NextLinkWrapper, - Image: NextImageWrapper, - getSearchParams, - useRouter: useNextStackRouter, - }; -} +export { toNextRouteHandlers } from "./handlers"; +export { + type CreateNextPageOptions, + type NextPageProps, + createNextPage, +} from "./page"; +export { nextRouter } from "./router"; +export type { + GetStackClient, + StackRequestHandler, +} from "../shared/entry-factories"; diff --git a/packages/stack/src/next/page.tsx b/packages/stack/src/next/page.tsx new file mode 100644 index 00000000..3e59b359 --- /dev/null +++ b/packages/stack/src/next/page.tsx @@ -0,0 +1,106 @@ +import { HydrationBoundary, dehydrate } from "@tanstack/react-query"; +import type { DehydrateOptions, QueryClient } from "@tanstack/react-query"; +import type { Metadata } from "next"; +import { notFound as nextNotFound } from "next/navigation"; +import type { ReactNode } from "react"; +import { metaElementsToObject } from "../client/meta-utils"; +import { normalizePath } from "../client/path-utils"; +import { + type GetStackClient, + stackDehydrateOptions, +} from "../shared/entry-factories"; + +export interface NextPageProps { + params: Promise<{ all?: string[] }>; +} + +export interface CreateNextPageOptions { + /** Returns the stack client for a given QueryClient (`lib/stack-client`). */ + getStackClient: GetStackClient; + /** Returns the QueryClient for the current context (`lib/query-client`). */ + getQueryClient: () => QueryClient; + /** + * Called when no route matches the path. Defaults to `notFound()` from + * `next/navigation`. + */ + notFound?: () => never; + /** Wraps the rendered page (inside the `HydrationBoundary`). */ + wrapPage?: (page: ReactNode) => ReactNode; + /** + * Options passed to `dehydrate()`. Defaults to dehydrating failed queries + * in addition to the React Query defaults, so the client does not refetch + * queries that errored during SSR. Override e.g. to sanitize error + * payloads before they are serialized into the HTML. + */ + dehydrateOptions?: DehydrateOptions; +} + +/** + * Creates the Next.js catch-all page for BTST plugin routes: SSR prefetch via + * `route.loader()`, React Query dehydration (including failed queries), + * `generateMetadata` with loader-before-meta ordering, and 404 via + * `notFound()`. + * + * @example + * ```tsx + * // app/pages/[[...all]]/page.tsx + * import { createNextPage } from "@btst/stack/next"; + * import { getOrCreateQueryClient } from "@/lib/query-client"; + * import { getStackClient } from "@/lib/stack-client"; + * + * export const dynamic = "force-dynamic"; + * const page = createNextPage({ getStackClient, getQueryClient: getOrCreateQueryClient }); + * export default page.Page; + * export const generateMetadata = page.generateMetadata; + * ``` + */ +export function createNextPage(options: CreateNextPageOptions) { + const { + getStackClient, + getQueryClient, + notFound = nextNotFound, + wrapPage, + dehydrateOptions = stackDehydrateOptions, + } = options; + + async function Page({ params }: NextPageProps) { + const pathParams = await params; + const path = normalizePath(pathParams?.all); + const queryClient = getQueryClient(); + const route = getStackClient(queryClient).router.getRoute(path); + + if (route?.loader) { + await route.loader(); + } + + const page = route?.PageComponent ? : notFound(); + + return ( + + {wrapPage ? wrapPage(page) : page} + + ); + } + + async function generateMetadata({ + params, + }: NextPageProps): Promise { + const pathParams = await params; + const path = normalizePath(pathParams?.all); + const queryClient = getQueryClient(); + const route = getStackClient(queryClient).router.getRoute(path); + + if (!route) { + return notFound(); + } + if (!route.meta) { + return {}; + } + if (route.loader) { + await route.loader(); + } + return metaElementsToObject(await route.meta()) satisfies Metadata; + } + + return { Page, generateMetadata }; +} diff --git a/packages/stack/src/next/router.tsx b/packages/stack/src/next/router.tsx new file mode 100644 index 00000000..2d4af4c3 --- /dev/null +++ b/packages/stack/src/next/router.tsx @@ -0,0 +1,102 @@ +"use client"; +import NextImage from "next/image"; +import NextLink from "next/link"; +import { useRouter } from "next/navigation"; +import { useMemo } from "react"; +import type { StackRouter, StackRouterConfig } from "../context/router"; + +function NextLinkWrapper({ + href, + ...props +}: React.ComponentProps<"a"> & Record) { + return ; +} + +/** + * Next.js Image wrapper for plugins. + * Handles both cases: with explicit dimensions or using fill mode. + */ +function NextImageWrapper(props: React.ImgHTMLAttributes) { + const { alt = "", src = "", width, height, ...rest } = props; + + // Use fill mode if width or height are not provided + if (!width || !height) { + return ( + + + + ); + } + + return ( + + ); +} + +// Reads window.location.search instead of Next's useSearchParams() hook to +// avoid forcing a Suspense/CSR bailout during static generation. Returns +// empty params on the server. +function getSearchParams(): URLSearchParams { + return new URLSearchParams( + typeof window !== "undefined" ? window.location.search : "", + ); +} + +function useNextStackRouter(): StackRouter { + const router = useRouter(); + + return useMemo( + () => ({ + navigate: (path: string) => { + router.push(path); + }, + refresh: () => { + router.refresh(); + }, + setSearchParams: ( + next: URLSearchParams, + opts?: { replace?: boolean }, + ) => { + const query = next.toString(); + const path = `${window.location.pathname}${query ? `?${query}` : ""}`; + if (opts?.replace) { + router.replace(path); + } else { + router.push(path); + } + }, + }), + [router], + ); +} + +/** + * Router preset for Next.js (App Router). + * + * @example + * ```tsx + * import { nextRouter } from "@btst/stack/next"; + * + * + * ``` + */ +export function nextRouter(): StackRouterConfig { + return { + Link: NextLinkWrapper, + Image: NextImageWrapper, + getSearchParams, + useRouter: useNextStackRouter, + }; +} diff --git a/packages/stack/src/react-router/handlers.ts b/packages/stack/src/react-router/handlers.ts new file mode 100644 index 00000000..ee3c88e0 --- /dev/null +++ b/packages/stack/src/react-router/handlers.ts @@ -0,0 +1,26 @@ +import type { StackRequestHandler } from "../shared/entry-factories"; + +/** + * Wires a BTST API handler to the `loader`/`action` exports a React Router + * catch-all API route needs. + * + * Note: React Router's build cannot strip destructured exports from route + * modules, so export the fields individually rather than destructuring. + * + * @example + * ```ts + * // app/routes/api/data/$.ts + * import { toReactRouterHandlers } from "@btst/stack/react-router"; + * import { handler } from "~/lib/stack"; + * + * const handlers = toReactRouterHandlers(handler); + * export const loader = handlers.loader; + * export const action = handlers.action; + * ``` + */ +export function toReactRouterHandlers(handler: StackRequestHandler) { + return { + loader: ({ request }: { request: Request }) => handler(request), + action: ({ request }: { request: Request }) => handler(request), + }; +} diff --git a/packages/stack/src/react-router/index.tsx b/packages/stack/src/react-router/index.tsx index 27b0b40f..4b9783e5 100644 --- a/packages/stack/src/react-router/index.tsx +++ b/packages/stack/src/react-router/index.tsx @@ -1,63 +1,11 @@ -"use client"; -import { useMemo } from "react"; -import { - Link as ReactRouterLink, - useNavigate, - useRevalidator, - useSearchParams, -} from "react-router"; -import type { StackRouter, StackRouterConfig } from "../context/router"; - -function ReactRouterLinkWrapper({ - href, - children, - ...props -}: React.ComponentProps<"a"> & Record) { - return ( - - {children} - - ); -} - -function useReactRouterStackRouter(): StackRouter { - const navigate = useNavigate(); - const { revalidate } = useRevalidator(); - const [searchParams, setSearchParams] = useSearchParams(); - - return useMemo( - () => ({ - navigate: (path: string) => { - void navigate(path); - }, - refresh: () => { - void revalidate(); - }, - getSearchParams: () => new URLSearchParams(searchParams), - setSearchParams: ( - next: URLSearchParams, - opts?: { replace?: boolean }, - ) => { - setSearchParams(next, { replace: opts?.replace }); - }, - }), - [navigate, revalidate, searchParams, setSearchParams], - ); -} - -/** - * Router preset for React Router (v7). - * - * @example - * ```tsx - * import { reactRouter } from "@btst/stack/react-router"; - * - * - * ``` - */ -export function reactRouter(): StackRouterConfig { - return { - Link: ReactRouterLinkWrapper, - useRouter: useReactRouterStackRouter, - }; -} +export { toReactRouterHandlers } from "./handlers"; +export { + type CreateReactRouterPageOptions, + type ReactRouterPageLoaderArgs, + createReactRouterPage, +} from "./page"; +export { reactRouter } from "./router"; +export type { + GetStackClient, + StackRequestHandler, +} from "../shared/entry-factories"; diff --git a/packages/stack/src/react-router/page.tsx b/packages/stack/src/react-router/page.tsx new file mode 100644 index 00000000..0f384576 --- /dev/null +++ b/packages/stack/src/react-router/page.tsx @@ -0,0 +1,130 @@ +import { + HydrationBoundary, + dehydrate, + useQueryClient, +} from "@tanstack/react-query"; +import type { DehydrateOptions, QueryClient } from "@tanstack/react-query"; +import type { ComponentType, ReactNode } from "react"; +import { useLoaderData, useRouteError } from "react-router"; +import { normalizePath } from "../client/path-utils"; +import { + type GetStackClient, + stackDehydrateOptions, +} from "../shared/entry-factories"; + +/** + * Loose structural form of React Router's generated `Route.LoaderArgs` / + * `Route.MetaArgs` for a catch-all route. The generated `./+types/$` types + * are not available inside the library, so the factory types the subset it + * uses; the results remain assignable to the generated route exports. + */ +export interface ReactRouterPageLoaderArgs { + params: { "*"?: string } & Record; +} + +export interface CreateReactRouterPageOptions { + /** Returns the stack client for a given QueryClient (`lib/stack-client`). */ + getStackClient: GetStackClient; + /** Returns the QueryClient for the current context (`lib/query-client`). */ + getQueryClient: () => QueryClient; + /** Rendered when no route matches. Defaults to a "Route not found" div. */ + NotFound?: ComponentType; + /** + * Rendered when the route errors. Defaults to a `
` with the
+	 * stringified error — provide your own component for a production-safe
+	 * error UI (use `useRouteError()` from react-router to read the error).
+	 */
+	ErrorBoundary?: ComponentType;
+	/** Wraps the rendered page (inside the `HydrationBoundary`). */
+	wrapPage?: (page: ReactNode) => ReactNode;
+	/**
+	 * Options passed to `dehydrate()`. Defaults to dehydrating failed queries
+	 * in addition to the React Query defaults, so the client does not refetch
+	 * queries that errored during SSR. Override e.g. to sanitize error
+	 * payloads before they are serialized into the HTML.
+	 */
+	dehydrateOptions?: DehydrateOptions;
+}
+
+/**
+ * Creates the React Router catch-all route pieces for BTST plugin routes:
+ * SSR prefetch via `route.loader()`, React Query dehydration (including
+ * failed queries), and loader-before-meta ordering.
+ *
+ * @example
+ * ```tsx
+ * // app/routes/pages/$.tsx
+ * import { createReactRouterPage } from "@btst/stack/react-router";
+ * import { getOrCreateQueryClient } from "~/lib/query-client";
+ * import { getStackClient } from "~/lib/stack-client";
+ *
+ * const page = createReactRouterPage({ getStackClient, getQueryClient: getOrCreateQueryClient });
+ * export const loader = page.loader;
+ * export const meta = page.meta;
+ * export default page.Component;
+ * ```
+ */
+export function createReactRouterPage(options: CreateReactRouterPageOptions) {
+	const {
+		getStackClient,
+		getQueryClient,
+		NotFound,
+		ErrorBoundary: CustomErrorBoundary,
+		wrapPage,
+		dehydrateOptions = stackDehydrateOptions,
+	} = options;
+
+	async function loader({ params }: ReactRouterPageLoaderArgs) {
+		const queryClient = getQueryClient();
+		const path = normalizePath(params["*"]);
+		const route = getStackClient(queryClient).router.getRoute(path);
+
+		if (route?.loader) {
+			await route.loader();
+		}
+
+		return {
+			path,
+			dehydratedState: dehydrate(queryClient, dehydrateOptions),
+			meta: await route?.meta?.(),
+		};
+	}
+
+	type LoaderData = Awaited>;
+
+	// Recent React Router versions pass `loaderData`; older 7.x used `data`.
+	function meta(args: { loaderData?: LoaderData; data?: LoaderData }) {
+		return (args.loaderData ?? args.data)?.meta;
+	}
+
+	function Component() {
+		const data = useLoaderData();
+		const queryClient = useQueryClient();
+		const route = getStackClient(queryClient).router.getRoute(data.path);
+		const page = route?.PageComponent ? (
+			
+		) : NotFound ? (
+			
+		) : (
+			
Route not found
+ ); + + return ( + + {wrapPage ? wrapPage(page) : page} + + ); + } + + function DefaultErrorBoundary() { + const error = useRouteError(); + return
{String(error)}
; + } + + return { + loader, + meta, + Component, + ErrorBoundary: CustomErrorBoundary ?? DefaultErrorBoundary, + }; +} diff --git a/packages/stack/src/react-router/router.tsx b/packages/stack/src/react-router/router.tsx new file mode 100644 index 00000000..27b0b40f --- /dev/null +++ b/packages/stack/src/react-router/router.tsx @@ -0,0 +1,63 @@ +"use client"; +import { useMemo } from "react"; +import { + Link as ReactRouterLink, + useNavigate, + useRevalidator, + useSearchParams, +} from "react-router"; +import type { StackRouter, StackRouterConfig } from "../context/router"; + +function ReactRouterLinkWrapper({ + href, + children, + ...props +}: React.ComponentProps<"a"> & Record) { + return ( + + {children} + + ); +} + +function useReactRouterStackRouter(): StackRouter { + const navigate = useNavigate(); + const { revalidate } = useRevalidator(); + const [searchParams, setSearchParams] = useSearchParams(); + + return useMemo( + () => ({ + navigate: (path: string) => { + void navigate(path); + }, + refresh: () => { + void revalidate(); + }, + getSearchParams: () => new URLSearchParams(searchParams), + setSearchParams: ( + next: URLSearchParams, + opts?: { replace?: boolean }, + ) => { + setSearchParams(next, { replace: opts?.replace }); + }, + }), + [navigate, revalidate, searchParams, setSearchParams], + ); +} + +/** + * Router preset for React Router (v7). + * + * @example + * ```tsx + * import { reactRouter } from "@btst/stack/react-router"; + * + * + * ``` + */ +export function reactRouter(): StackRouterConfig { + return { + Link: ReactRouterLinkWrapper, + useRouter: useReactRouterStackRouter, + }; +} diff --git a/packages/stack/src/shared/entry-factories.ts b/packages/stack/src/shared/entry-factories.ts new file mode 100644 index 00000000..837c4c65 --- /dev/null +++ b/packages/stack/src/shared/entry-factories.ts @@ -0,0 +1,55 @@ +import { + type DehydrateOptions, + type QueryClient, + defaultShouldDehydrateQuery, +} from "@tanstack/react-query"; +import type { ComponentType } from "react"; + +/** + * Minimal structural view of a route returned by + * `createStackClient(...).router.getRoute(path)`. The framework entry + * factories only need these three fields. + */ +export interface StackRouteLike { + PageComponent?: ComponentType | undefined; + loader?: ((...args: any[]) => unknown) | undefined; + meta?: ((...args: any[]) => any) | undefined; +} + +/** + * Minimal structural view of the object returned by `createStackClient`. + * Any concrete `ClientLib` is assignable to this shape. + */ +export interface StackClientLike { + router: { + getRoute: ( + path: string, + queryParams?: Record, + ) => (StackRouteLike & Record) | null | undefined; + }; +} + +/** + * Consumer-provided factory that returns the stack client for a given + * QueryClient (per-request on the server, singleton on the client). + */ +export type GetStackClient = (queryClient: QueryClient) => StackClientLike; + +/** + * A framework-agnostic BTST API handler, as returned by + * `createBackendHandler(...).handler`. + */ +export type StackRequestHandler = ( + request: Request, +) => Response | Promise; + +/** + * Dehydration config owned by the page factories: dehydrate everything the + * default would, plus failed queries, so the client does not refetch (and + * flash a loading state) for queries that errored during SSR — regardless of + * how the consumer configured their QueryClient. + */ +export const stackDehydrateOptions: DehydrateOptions = { + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) || query.state.status === "error", +}; diff --git a/packages/stack/src/tanstack/handlers.ts b/packages/stack/src/tanstack/handlers.ts new file mode 100644 index 00000000..5445c7af --- /dev/null +++ b/packages/stack/src/tanstack/handlers.ts @@ -0,0 +1,28 @@ +import type { StackRequestHandler } from "../shared/entry-factories"; + +/** + * Wires a BTST API handler to the method handler map a TanStack Start server + * route needs. + * + * @example + * ```ts + * // src/routes/api/data/$.ts + * import { createFileRoute } from "@tanstack/react-router"; + * import { toTanStackHandlers } from "@btst/stack/tanstack"; + * import { handler } from "@/lib/stack"; + * + * export const Route = createFileRoute("/api/data/$")({ + * server: { handlers: toTanStackHandlers(handler) }, + * }); + * ``` + */ +export function toTanStackHandlers(handler: StackRequestHandler) { + const methodHandler = ({ request }: { request: Request }) => handler(request); + return { + GET: methodHandler, + POST: methodHandler, + PUT: methodHandler, + PATCH: methodHandler, + DELETE: methodHandler, + }; +} diff --git a/packages/stack/src/tanstack/index.tsx b/packages/stack/src/tanstack/index.tsx index 9451bf42..f91f0066 100644 --- a/packages/stack/src/tanstack/index.tsx +++ b/packages/stack/src/tanstack/index.tsx @@ -1,61 +1,10 @@ -"use client"; -import { Link as TanStackLink, useRouter } from "@tanstack/react-router"; -import { useMemo } from "react"; -import type { StackRouter, StackRouterConfig } from "../context/router"; - -function TanStackLinkWrapper({ - href, - children, - ...props -}: React.ComponentProps<"a"> & Record) { - return ( - - {children} - - ); -} - -function useTanStackStackRouter(): StackRouter { - const router = useRouter(); - - return useMemo( - () => ({ - navigate: (path: string) => { - router.navigate({ href: path }); - }, - refresh: () => { - router.invalidate(); - }, - getSearchParams: () => - new URLSearchParams(router.state.location.searchStr ?? ""), - setSearchParams: ( - next: URLSearchParams, - opts?: { replace?: boolean }, - ) => { - const query = next.toString(); - router.navigate({ - href: `${router.state.location.pathname}${query ? `?${query}` : ""}`, - replace: opts?.replace, - }); - }, - }), - [router], - ); -} - -/** - * Router preset for TanStack Router / TanStack Start. - * - * @example - * ```tsx - * import { tanstackRouter } from "@btst/stack/tanstack"; - * - * - * ``` - */ -export function tanstackRouter(): StackRouterConfig { - return { - Link: TanStackLinkWrapper, - useRouter: useTanStackStackRouter, - }; -} +export { toTanStackHandlers } from "./handlers"; +export { + type CreateTanStackPageOptions, + createTanStackPageOptions, +} from "./page"; +export { tanstackRouter } from "./router"; +export type { + GetStackClient, + StackRequestHandler, +} from "../shared/entry-factories"; diff --git a/packages/stack/src/tanstack/page.tsx b/packages/stack/src/tanstack/page.tsx new file mode 100644 index 00000000..0cd9e164 --- /dev/null +++ b/packages/stack/src/tanstack/page.tsx @@ -0,0 +1,88 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { notFound, useParams, useRouteContext } from "@tanstack/react-router"; +import { normalizePath } from "../client/path-utils"; +import type { GetStackClient } from "../shared/entry-factories"; + +export interface CreateTanStackPageOptions { + /** Returns the stack client for a given QueryClient (`lib/stack-client`). */ + getStackClient: GetStackClient; + /** + * Returns the QueryClient for the current context. Defaults to the + * `queryClient` from the router context (set up by + * `setupRouterSsrQueryIntegration`). + */ + getQueryClient?: () => QueryClient; +} + +interface TanStackPageLoaderArgs { + params: { _splat?: string }; + context?: unknown; +} + +/** + * Creates the route options for the TanStack Start catch-all page, to spread + * into `createFileRoute("/pages/$")(...)`: SSR prefetch via `route.loader()`, + * head/meta from `route.meta()` with loader-before-meta ordering, and 404 via + * `notFound()`. Query cache dehydration is handled by TanStack's router-query + * SSR integration. + * + * @example + * ```tsx + * // src/routes/pages/$.tsx + * import { createFileRoute } from "@tanstack/react-router"; + * import { createTanStackPageOptions } from "@btst/stack/tanstack"; + * import { getStackClient } from "@/lib/stack-client"; + * + * export const Route = createFileRoute("/pages/$")( + * createTanStackPageOptions({ getStackClient }), + * ); + * ``` + */ +export function createTanStackPageOptions(options: CreateTanStackPageOptions) { + const { getStackClient, getQueryClient } = options; + + function resolveQueryClient(context: unknown): QueryClient { + const fromContext = (context as { queryClient?: QueryClient } | null) + ?.queryClient; + const queryClient = getQueryClient?.() ?? fromContext; + if (!queryClient) { + throw new Error( + "createTanStackPageOptions: no QueryClient available. Provide `getQueryClient` or add `queryClient` to the router context.", + ); + } + return queryClient; + } + + function PageComponent() { + const params = useParams({ strict: false }) as { _splat?: string }; + const context = useRouteContext({ strict: false }); + const routePath = normalizePath(params._splat); + const route = getStackClient(resolveQueryClient(context)).router.getRoute( + routePath, + ); + return route?.PageComponent ? ( + + ) : ( +
Route not found
+ ); + } + + return { + ssr: true, + component: PageComponent, + loader: async ({ params, context }: TanStackPageLoaderArgs) => { + const queryClient = resolveQueryClient(context); + const routePath = normalizePath(params._splat); + const route = getStackClient(queryClient).router.getRoute(routePath); + if (!route) throw notFound(); + if (route.loader) await route.loader(); + return { meta: await route.meta?.() }; + }, + head: ({ loaderData }: { loaderData?: { meta?: unknown } }) => { + if (!loaderData?.meta || !Array.isArray(loaderData.meta)) { + return { title: "No Meta", meta: [{ title: "No Meta" }] }; + } + return { meta: loaderData.meta }; + }, + } as const; +} diff --git a/packages/stack/src/tanstack/router.tsx b/packages/stack/src/tanstack/router.tsx new file mode 100644 index 00000000..9451bf42 --- /dev/null +++ b/packages/stack/src/tanstack/router.tsx @@ -0,0 +1,61 @@ +"use client"; +import { Link as TanStackLink, useRouter } from "@tanstack/react-router"; +import { useMemo } from "react"; +import type { StackRouter, StackRouterConfig } from "../context/router"; + +function TanStackLinkWrapper({ + href, + children, + ...props +}: React.ComponentProps<"a"> & Record) { + return ( + + {children} + + ); +} + +function useTanStackStackRouter(): StackRouter { + const router = useRouter(); + + return useMemo( + () => ({ + navigate: (path: string) => { + router.navigate({ href: path }); + }, + refresh: () => { + router.invalidate(); + }, + getSearchParams: () => + new URLSearchParams(router.state.location.searchStr ?? ""), + setSearchParams: ( + next: URLSearchParams, + opts?: { replace?: boolean }, + ) => { + const query = next.toString(); + router.navigate({ + href: `${router.state.location.pathname}${query ? `?${query}` : ""}`, + replace: opts?.replace, + }); + }, + }), + [router], + ); +} + +/** + * Router preset for TanStack Router / TanStack Start. + * + * @example + * ```tsx + * import { tanstackRouter } from "@btst/stack/tanstack"; + * + * + * ``` + */ +export function tanstackRouter(): StackRouterConfig { + return { + Link: TanStackLinkWrapper, + useRouter: useTanStackStackRouter, + }; +} diff --git a/scripts/codegen/files/tanstack/src/routes/pages/$.tsx b/scripts/codegen/files/tanstack/src/routes/pages/$.tsx index 48cad060..df282181 100644 --- a/scripts/codegen/files/tanstack/src/routes/pages/$.tsx +++ b/scripts/codegen/files/tanstack/src/routes/pages/$.tsx @@ -1,34 +1,7 @@ -import { createFileRoute, notFound } from "@tanstack/react-router"; -import { normalizePath } from "@btst/stack/client"; +import { createTanStackPageOptions } from "@btst/stack/tanstack"; +import { createFileRoute } from "@tanstack/react-router"; import { getStackClient } from "@/lib/stack-client"; -export const Route = createFileRoute("/pages/$")({ - ssr: true, - component: BtstPagesRoute, - loader: async ({ params, context }) => { - const queryClient = context.queryClient; - const routePath = normalizePath(params._splat); - const route = getStackClient(queryClient).router.getRoute(routePath); - if (!route) throw notFound(); - if (route.loader) await route.loader(); - return { meta: route.meta?.() }; - }, - head: ({ loaderData }) => { - if (!loaderData?.meta || !Array.isArray(loaderData.meta)) { - return { title: "No Meta", meta: [{ title: "No Meta" }] }; - } - return { meta: loaderData.meta }; - }, -}); - -function BtstPagesRoute() { - const params = Route.useParams(); - const { queryClient } = Route.useRouteContext(); - const routePath = normalizePath(params._splat); - const route = getStackClient(queryClient).router.getRoute(routePath); - return route?.PageComponent ? ( - - ) : ( -
Route not found
- ); -} +export const Route = createFileRoute("/pages/$")( + createTanStackPageOptions({ getStackClient }), +);