From 5a48214cb135e6743493138f24bbc0db66315451 Mon Sep 17 00:00:00 2001 From: jon Date: Tue, 5 May 2026 16:22:35 +0100 Subject: [PATCH 01/11] feat: prerenderParams --- .changeset/tall-trees-prerender-params.md | 7 + .../react/guide/static-prerendering.md | 46 ++ .../solid/guide/static-prerendering.md | 46 ++ e2e/react-start/basic/src/routeTree.gen.ts | 43 ++ .../src/routes/-prerender-params.server.ts | 11 + .../_layout-2/prerender-nested.$slug.tsx | 14 + .../src/routes/prerender-params.$slug.tsx | 60 ++ e2e/react-start/basic/start-mode-config.ts | 6 + .../basic/tests/prerendering.spec.ts | 121 +++- e2e/solid-start/basic/package.json | 4 + e2e/solid-start/basic/rsbuild.config.ts | 23 +- e2e/solid-start/basic/src/routeTree.gen.ts | 43 ++ .../src/routes/-prerender-params.server.ts | 11 + .../_layout-2/prerender-nested.$slug.tsx | 14 + .../src/routes/prerender-params.$slug.tsx | 60 ++ .../basic/tests/prerendering.spec.ts | 127 ++++- e2e/solid-start/basic/vite.config.ts | 6 + e2e/vue-start/basic/package.json | 4 + e2e/vue-start/basic/rsbuild.config.ts | 22 +- e2e/vue-start/basic/src/routeTree.gen.ts | 43 ++ .../src/routes/-prerender-params.server.ts | 11 + .../_layout-2/prerender-nested.$slug.tsx | 14 + .../src/routes/prerender-params.$slug.tsx | 60 ++ .../basic/tests/prerendering.spec.ts | 127 ++++- e2e/vue-start/basic/vite.config.ts | 6 + packages/start-client-core/src/index.tsx | 1 + .../start-client-core/src/prerenderParams.ts | 108 ++++ .../src/tests/prerenderParams.test-d.ts | 226 ++++++++ .../start-plugin-core/src/build-sitemap.ts | 2 + packages/start-plugin-core/src/global.d.ts | 11 + packages/start-plugin-core/src/post-build.ts | 5 +- .../src/prerender-params-runner.ts | 215 ++++++++ .../src/prerender-route-options.ts | 80 +++ packages/start-plugin-core/src/prerender.ts | 13 +- .../src/rsbuild/post-build.ts | 30 +- .../src/rsbuild/start-router-plugin.ts | 5 +- packages/start-plugin-core/src/schema.ts | 1 + .../src/start-router-plugin/constants.ts | 7 + .../prerender-routes-plugin.ts | 11 + .../start-plugin-core/src/vite/prerender.ts | 20 +- .../src/vite/start-router-plugin/plugin.ts | 10 +- .../tests/build-sitemap.test.ts | 158 ++++++ .../tests/prerender-params-runner.test.ts | 522 ++++++++++++++++++ .../tests/prerender-routes-plugin.test.ts | 111 ++++ .../tests/prerender-ssrf.test.ts | 19 +- .../start-router-plugin-constants.test.ts | 14 + .../src/createStartHandler.ts | 10 + packages/start-server-core/src/global.d.ts | 8 + 48 files changed, 2490 insertions(+), 26 deletions(-) create mode 100644 .changeset/tall-trees-prerender-params.md create mode 100644 e2e/react-start/basic/src/routes/-prerender-params.server.ts create mode 100644 e2e/react-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx create mode 100644 e2e/react-start/basic/src/routes/prerender-params.$slug.tsx create mode 100644 e2e/solid-start/basic/src/routes/-prerender-params.server.ts create mode 100644 e2e/solid-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx create mode 100644 e2e/solid-start/basic/src/routes/prerender-params.$slug.tsx create mode 100644 e2e/vue-start/basic/src/routes/-prerender-params.server.ts create mode 100644 e2e/vue-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx create mode 100644 e2e/vue-start/basic/src/routes/prerender-params.$slug.tsx create mode 100644 packages/start-client-core/src/prerenderParams.ts create mode 100644 packages/start-client-core/src/tests/prerenderParams.test-d.ts create mode 100644 packages/start-plugin-core/src/prerender-params-runner.ts create mode 100644 packages/start-plugin-core/src/prerender-route-options.ts create mode 100644 packages/start-plugin-core/tests/build-sitemap.test.ts create mode 100644 packages/start-plugin-core/tests/prerender-params-runner.test.ts create mode 100644 packages/start-plugin-core/tests/prerender-routes-plugin.test.ts create mode 100644 packages/start-plugin-core/tests/start-router-plugin-constants.test.ts diff --git a/.changeset/tall-trees-prerender-params.md b/.changeset/tall-trees-prerender-params.md new file mode 100644 index 00000000000..4af009b383e --- /dev/null +++ b/.changeset/tall-trees-prerender-params.md @@ -0,0 +1,7 @@ +--- +'@tanstack/start-client-core': minor +'@tanstack/start-plugin-core': minor +'@tanstack/start-server-core': minor +--- + +Add typed route `prerenderParams` support for generating dynamic prerender pages at build time. diff --git a/docs/start/framework/react/guide/static-prerendering.md b/docs/start/framework/react/guide/static-prerendering.md index dfaac8fcef5..628291a9230 100644 --- a/docs/start/framework/react/guide/static-prerendering.md +++ b/docs/start/framework/react/guide/static-prerendering.md @@ -46,6 +46,9 @@ export default defineConfig({ // Maximum number of redirects to follow during prerendering maxRedirects: 5, + // Maximum time in milliseconds to wait for each prerenderParams callback + prerenderParamsTimeout: 30000, + // Fail if an error occurs during prerendering failOnError: true, @@ -81,6 +84,49 @@ Routes are excluded from automatic discovery in the following cases: Note: Dynamic routes can still be prerendered if they are linked from other pages when `crawlLinks` is enabled. +## Dynamic Route Prerendering + +Dynamic routes can declare `prerenderParams` to generate specific parameter values at build time. Each returned entry creates one page from the route path and can override sitemap or prerender options for that page. + +```tsx +// src/routes/posts/$postId.tsx +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts/$postId')({ + validateSearch: (search: Record): { ref?: string } => ({ + ...(typeof search.ref === 'string' ? { ref: search.ref } : {}), + }), + sitemap: { + changefreq: 'weekly', + }, + prerenderParams: async () => { + const posts = await fetchPosts() + + return posts.map((post) => ({ + params: { postId: post.id }, + search: { ref: 'sitemap' }, + sitemap: { + lastmod: post.updatedAt, + priority: 0.8, + }, + })) + }, + component: PostComponent, +}) + +function PostComponent() { + const { postId } = Route.useParams() + + return
Post {postId}
+} +``` + +`prerenderParams` receives `{ routePath, signal }`. The signal aborts when the build process is interrupted and when `prerender.prerenderParamsTimeout` elapses. Each entry's `params` and optional `search` values are typed from the route and used to create the generated URL. Search params are preserved in generated page paths and sitemap URLs using the router's default search serialization; custom `stringifySearch` router options are not applied during this build-time expansion. The route-level `sitemap` option applies to every generated page, and `entry.sitemap` is merged on top for a specific parameter entry. Use `entry.sitemap.exclude` to generate the HTML page without adding it to the sitemap. + +The `sitemap` route option only controls metadata for generated sitemap entries. It does not enable sitemap output by itself; sitemap XML is still controlled by the top-level `sitemap` configuration in your Start plugin config. + +Code that is only referenced by `prerenderParams` or `sitemap` is removed from the client route bundle, so these options can import server-only data sources used to discover pages at build time. + ## Crawling Links When `crawlLinks` is enabled (default: `true`), TanStack Start will extract links from prerendered pages and prerender those linked pages as well. diff --git a/docs/start/framework/solid/guide/static-prerendering.md b/docs/start/framework/solid/guide/static-prerendering.md index 5a98af5e6c5..07f0daf58fa 100644 --- a/docs/start/framework/solid/guide/static-prerendering.md +++ b/docs/start/framework/solid/guide/static-prerendering.md @@ -46,6 +46,9 @@ export default defineConfig({ // Maximum number of redirects to follow during prerendering maxRedirects: 5, + // Maximum time in milliseconds to wait for each prerenderParams callback + prerenderParamsTimeout: 30000, + // Fail if an error occurs during prerendering failOnError: true, @@ -81,6 +84,49 @@ Routes are excluded from automatic discovery in the following cases: Note: Dynamic routes can still be prerendered if they are linked from other pages when `crawlLinks` is enabled. +## Dynamic Route Prerendering + +Dynamic routes can declare `prerenderParams` to generate specific parameter values at build time. Each returned entry creates one page from the route path and can override sitemap or prerender options for that page. + +```tsx +// src/routes/posts/$postId.tsx +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/posts/$postId')({ + validateSearch: (search: Record): { ref?: string } => ({ + ...(typeof search.ref === 'string' ? { ref: search.ref } : {}), + }), + sitemap: { + changefreq: 'weekly', + }, + prerenderParams: async () => { + const posts = await fetchPosts() + + return posts.map((post) => ({ + params: { postId: post.id }, + search: { ref: 'sitemap' }, + sitemap: { + lastmod: post.updatedAt, + priority: 0.8, + }, + })) + }, + component: PostComponent, +}) + +function PostComponent() { + const params = Route.useParams() + + return
Post {params().postId}
+} +``` + +`prerenderParams` receives `{ routePath, signal }`. The signal aborts when the build process is interrupted and when `prerender.prerenderParamsTimeout` elapses. Each entry's `params` and optional `search` values are typed from the route and used to create the generated URL. Search params are preserved in generated page paths and sitemap URLs using the router's default search serialization; custom `stringifySearch` router options are not applied during this build-time expansion. The route-level `sitemap` option applies to every generated page, and `entry.sitemap` is merged on top for a specific parameter entry. Use `entry.sitemap.exclude` to generate the HTML page without adding it to the sitemap. + +The `sitemap` route option only controls metadata for generated sitemap entries. It does not enable sitemap output by itself; sitemap XML is still controlled by the top-level `sitemap` configuration in your Start plugin config. + +Code that is only referenced by `prerenderParams` or `sitemap` is removed from the client route bundle, so these options can import server-only data sources used to discover pages at build time. + ## Crawling Links When `crawlLinks` is enabled (default: `true`), TanStack Start will extract links from prerendered pages and prerender those linked pages as well. diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts index b32796cafc7..58fa35aa07a 100644 --- a/e2e/react-start/basic/src/routeTree.gen.ts +++ b/e2e/react-start/basic/src/routeTree.gen.ts @@ -47,6 +47,7 @@ import { Route as RawStreamSsrMultipleRouteImport } from './routes/raw-stream/ss import { Route as RawStreamSsrMixedRouteImport } from './routes/raw-stream/ssr-mixed' import { Route as RawStreamSsrBinaryHintRouteImport } from './routes/raw-stream/ssr-binary-hint' import { Route as RawStreamClientCallRouteImport } from './routes/raw-stream/client-call' +import { Route as PrerenderParamsSlugRouteImport } from './routes/prerender-params.$slug' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' import { Route as NotFoundViaLoaderRouteImport } from './routes/not-found/via-loader' import { Route as NotFoundViaBeforeLoadTargetRootRouteImport } from './routes/not-found/via-beforeLoad-target-root' @@ -75,6 +76,7 @@ import { Route as RedirectTargetServerFnViaUseServerFnRouteImport } from './rout import { Route as RedirectTargetServerFnViaLoaderRouteImport } from './routes/redirect/$target/serverFn/via-loader' import { Route as RedirectTargetServerFnViaBeforeLoadRouteImport } from './routes/redirect/$target/serverFn/via-beforeLoad' import { Route as FooBarQuxHereRouteImport } from './routes/foo/$bar/$qux/_here' +import { Route as LayoutLayout2PrerenderNestedSlugRouteImport } from './routes/_layout/_layout-2/prerender-nested.$slug' import { Route as NotFoundDeepBCRouteRouteImport } from './routes/not-found/deep/b/c/route' import { Route as FooBarQuxHereIndexRouteImport } from './routes/foo/$bar/$qux/_here/index' import { Route as NotFoundDeepBCDRouteImport } from './routes/not-found/deep/b/c/d' @@ -271,6 +273,11 @@ const RawStreamClientCallRoute = RawStreamClientCallRouteImport.update({ path: '/client-call', getParentRoute: () => RawStreamRoute, } as any) +const PrerenderParamsSlugRoute = PrerenderParamsSlugRouteImport.update({ + id: '/prerender-params/$slug', + path: '/prerender-params/$slug', + getParentRoute: () => rootRouteImport, +} as any) const PostsPostIdRoute = PostsPostIdRouteImport.update({ id: '/$postId', path: '/$postId', @@ -423,6 +430,12 @@ const FooBarQuxHereRoute = FooBarQuxHereRouteImport.update({ path: '/foo/$bar/$qux', getParentRoute: () => rootRouteImport, } as any) +const LayoutLayout2PrerenderNestedSlugRoute = + LayoutLayout2PrerenderNestedSlugRouteImport.update({ + id: '/prerender-nested/$slug', + path: '/prerender-nested/$slug', + getParentRoute: () => LayoutLayout2Route, + } as any) const NotFoundDeepBCRouteRoute = NotFoundDeepBCRouteRouteImport.update({ id: '/c', path: '/c', @@ -465,6 +478,7 @@ export interface FileRoutesByFullPath { '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/prerender-params/$slug': typeof PrerenderParamsSlugRoute '/raw-stream/client-call': typeof RawStreamClientCallRoute '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute @@ -500,6 +514,7 @@ export interface FileRoutesByFullPath { '/not-found/parent-boundary/': typeof NotFoundParentBoundaryIndexRoute '/redirect/$target/': typeof RedirectTargetIndexRoute '/not-found/deep/b/c': typeof NotFoundDeepBCRouteRouteWithChildren + '/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute '/foo/$bar/$qux': typeof FooBarQuxHereRouteWithChildren '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute @@ -527,6 +542,7 @@ export interface FileRoutesByTo { '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/prerender-params/$slug': typeof PrerenderParamsSlugRoute '/raw-stream/client-call': typeof RawStreamClientCallRoute '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute @@ -561,6 +577,7 @@ export interface FileRoutesByTo { '/not-found/parent-boundary': typeof NotFoundParentBoundaryIndexRoute '/redirect/$target': typeof RedirectTargetIndexRoute '/not-found/deep/b/c': typeof NotFoundDeepBCRouteRouteWithChildren + '/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute '/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute @@ -597,6 +614,7 @@ export interface FileRoutesById { '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/prerender-params/$slug': typeof PrerenderParamsSlugRoute '/raw-stream/client-call': typeof RawStreamClientCallRoute '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute @@ -632,6 +650,7 @@ export interface FileRoutesById { '/not-found/parent-boundary/': typeof NotFoundParentBoundaryIndexRoute '/redirect/$target/': typeof RedirectTargetIndexRoute '/not-found/deep/b/c': typeof NotFoundDeepBCRouteRouteWithChildren + '/_layout/_layout-2/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute '/foo/$bar/$qux/_here': typeof FooBarQuxHereRouteWithChildren '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute @@ -668,6 +687,7 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad-target-root' | '/not-found/via-loader' | '/posts/$postId' + | '/prerender-params/$slug' | '/raw-stream/client-call' | '/raw-stream/ssr-binary-hint' | '/raw-stream/ssr-mixed' @@ -703,6 +723,7 @@ export interface FileRouteTypes { | '/not-found/parent-boundary/' | '/redirect/$target/' | '/not-found/deep/b/c' + | '/prerender-nested/$slug' | '/foo/$bar/$qux' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' @@ -730,6 +751,7 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad-target-root' | '/not-found/via-loader' | '/posts/$postId' + | '/prerender-params/$slug' | '/raw-stream/client-call' | '/raw-stream/ssr-binary-hint' | '/raw-stream/ssr-mixed' @@ -764,6 +786,7 @@ export interface FileRouteTypes { | '/not-found/parent-boundary' | '/redirect/$target' | '/not-found/deep/b/c' + | '/prerender-nested/$slug' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' | '/redirect/$target/serverFn/via-useServerFn' @@ -799,6 +822,7 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad-target-root' | '/not-found/via-loader' | '/posts/$postId' + | '/prerender-params/$slug' | '/raw-stream/client-call' | '/raw-stream/ssr-binary-hint' | '/raw-stream/ssr-mixed' @@ -834,6 +858,7 @@ export interface FileRouteTypes { | '/not-found/parent-boundary/' | '/redirect/$target/' | '/not-found/deep/b/c' + | '/_layout/_layout-2/prerender-nested/$slug' | '/foo/$bar/$qux/_here' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' @@ -863,6 +888,7 @@ export interface RootRouteChildren { UsersRoute: typeof UsersRouteWithChildren ApiUsersRoute: typeof ApiUsersRouteWithChildren MultiCookieRedirectTargetRoute: typeof MultiCookieRedirectTargetRoute + PrerenderParamsSlugRoute: typeof PrerenderParamsSlugRoute RedirectTargetRoute: typeof RedirectTargetRouteWithChildren MultiCookieRedirectIndexRoute: typeof MultiCookieRedirectIndexRoute RedirectIndexRoute: typeof RedirectIndexRoute @@ -1138,6 +1164,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RawStreamClientCallRouteImport parentRoute: typeof RawStreamRoute } + '/prerender-params/$slug': { + id: '/prerender-params/$slug' + path: '/prerender-params/$slug' + fullPath: '/prerender-params/$slug' + preLoaderRoute: typeof PrerenderParamsSlugRouteImport + parentRoute: typeof rootRouteImport + } '/posts/$postId': { id: '/posts/$postId' path: '/$postId' @@ -1334,6 +1367,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof FooBarQuxHereRouteImport parentRoute: typeof rootRouteImport } + '/_layout/_layout-2/prerender-nested/$slug': { + id: '/_layout/_layout-2/prerender-nested/$slug' + path: '/prerender-nested/$slug' + fullPath: '/prerender-nested/$slug' + preLoaderRoute: typeof LayoutLayout2PrerenderNestedSlugRouteImport + parentRoute: typeof LayoutLayout2Route + } '/not-found/deep/b/c': { id: '/not-found/deep/b/c' path: '/c' @@ -1487,11 +1527,13 @@ const SpecialCharsRouteRouteWithChildren = interface LayoutLayout2RouteChildren { LayoutLayout2LayoutARoute: typeof LayoutLayout2LayoutARoute LayoutLayout2LayoutBRoute: typeof LayoutLayout2LayoutBRoute + LayoutLayout2PrerenderNestedSlugRoute: typeof LayoutLayout2PrerenderNestedSlugRoute } const LayoutLayout2RouteChildren: LayoutLayout2RouteChildren = { LayoutLayout2LayoutARoute: LayoutLayout2LayoutARoute, LayoutLayout2LayoutBRoute: LayoutLayout2LayoutBRoute, + LayoutLayout2PrerenderNestedSlugRoute: LayoutLayout2PrerenderNestedSlugRoute, } const LayoutLayout2RouteWithChildren = LayoutLayout2Route._addFileChildren( @@ -1627,6 +1669,7 @@ const rootRouteChildren: RootRouteChildren = { UsersRoute: UsersRouteWithChildren, ApiUsersRoute: ApiUsersRouteWithChildren, MultiCookieRedirectTargetRoute: MultiCookieRedirectTargetRoute, + PrerenderParamsSlugRoute: PrerenderParamsSlugRoute, RedirectTargetRoute: RedirectTargetRouteWithChildren, MultiCookieRedirectIndexRoute: MultiCookieRedirectIndexRoute, RedirectIndexRoute: RedirectIndexRoute, diff --git a/e2e/react-start/basic/src/routes/-prerender-params.server.ts b/e2e/react-start/basic/src/routes/-prerender-params.server.ts new file mode 100644 index 00000000000..5593659c824 --- /dev/null +++ b/e2e/react-start/basic/src/routes/-prerender-params.server.ts @@ -0,0 +1,11 @@ +import '@tanstack/react-start/server-only' + +export const SERVER_ONLY_PRERENDER_MARKER = + 'server-only-prerender-marker-should-not-be-in-client' + +export function getServerOnlyPrerenderSlug() { + return SERVER_ONLY_PRERENDER_MARKER.replace( + 'server-only-prerender-marker-should-not-be-in-client', + 'server-only-slug', + ) +} diff --git a/e2e/react-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx b/e2e/react-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx new file mode 100644 index 00000000000..3e4094ce8f3 --- /dev/null +++ b/e2e/react-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute( + '/_layout/_layout-2/prerender-nested/$slug', +)({ + prerenderParams: () => [{ params: { slug: 'under-layout' } }], + component: RouteComponent, +}) + +function RouteComponent() { + const params = Route.useParams() + + return
Nested prerendered slug: {params.slug}
+} diff --git a/e2e/react-start/basic/src/routes/prerender-params.$slug.tsx b/e2e/react-start/basic/src/routes/prerender-params.$slug.tsx new file mode 100644 index 00000000000..96e42365584 --- /dev/null +++ b/e2e/react-start/basic/src/routes/prerender-params.$slug.tsx @@ -0,0 +1,60 @@ +import { createFileRoute } from '@tanstack/react-router' +import z from 'zod' +import { getServerOnlyPrerenderSlug } from './-prerender-params.server' + +export const Route = createFileRoute('/prerender-params/$slug')({ + validateSearch: z.object({ + page: z.number().optional(), + tag: z.string().optional(), + }), + sitemap: { + changefreq: 'weekly', + }, + prerenderParams: () => [ + { + params: { slug: 'hello-world' }, + sitemap: { + lastmod: '2026-05-05', + priority: 0.8, + }, + }, + { + params: { slug: '대한민국' }, + sitemap: { + priority: 0.6, + }, + }, + { + params: { slug: 'reserved?hash#plus+' }, + sitemap: { + exclude: true, + }, + }, + { + params: { slug: 'with-query' }, + search: { page: 2, tag: 'router start' }, + sitemap: { + priority: 0.4, + }, + }, + { + params: { slug: getServerOnlyPrerenderSlug() }, + sitemap: { + exclude: true, + }, + }, + ], + component: RouteComponent, +}) + +function RouteComponent() { + const { slug } = Route.useParams() + const search = Route.useSearch() + + return ( +
+ Prerendered slug: {slug}. Search page: {search.page ?? 'none'}. Search + tag: {search.tag ?? 'none'} +
+ ) +} diff --git a/e2e/react-start/basic/start-mode-config.ts b/e2e/react-start/basic/start-mode-config.ts index 3cb6db26586..3c3f2147573 100644 --- a/e2e/react-start/basic/start-mode-config.ts +++ b/e2e/react-start/basic/start-mode-config.ts @@ -28,5 +28,11 @@ export function getStartModeConfig() { maxRedirects: 100, } : undefined, + sitemap: isPrerender + ? { + enabled: true, + host: 'https://example.com', + } + : undefined, } } diff --git a/e2e/react-start/basic/tests/prerendering.spec.ts b/e2e/react-start/basic/tests/prerendering.spec.ts index ddcf720a4c0..8d7531bb5a6 100644 --- a/e2e/react-start/basic/tests/prerendering.spec.ts +++ b/e2e/react-start/basic/tests/prerendering.spec.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { join } from 'node:path' import { expect } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' @@ -44,5 +44,124 @@ test.describe('Prerender Static Path Discovery', () => { const html = readFileSync(join(distDir, 'posts/index.html'), 'utf-8') expect(html).toContain('Select a post.') }) + + test('should prerender static routes through outlets', () => { + const htmlPath = join(distDir, 'layout-a/index.html') + + expect(existsSync(htmlPath)).toBe(true) + + const html = readFileSync(htmlPath, 'utf-8') + .replaceAll(''', "'") + .replaceAll(''', "'") + .replaceAll(''', "'") + expect(html).toContain("I'm a layout") + expect(html).toContain("I'm a nested layout") + expect(html).toContain("I'm layout A!") + }) + + test('should prerender dynamic routes through nested pathless outlets', () => { + const htmlPath = join( + distDir, + 'prerender-nested/under-layout/index.html', + ) + + expect(existsSync(htmlPath)).toBe(true) + + const html = readFileSync(htmlPath, 'utf-8') + .replaceAll(''', "'") + .replaceAll(''', "'") + .replaceAll(''', "'") + expect(html).toContain("I'm a layout") + expect(html).toContain("I'm a nested layout") + expect(html).toContain('Nested prerendered slug: under-layout') + }) + + test('should contain prerendered content from route prerenderParams', () => { + const htmlPath = join(distDir, 'prerender-params/hello-world/index.html') + + expect(existsSync(htmlPath)).toBe(true) + + const html = readFileSync(htmlPath, 'utf-8') + expect(html).toContain('Prerendered slug: hello-world') + }) + + test('should support special characters from route prerenderParams', () => { + const htmlPath = join(distDir, 'prerender-params/대한민국/index.html') + + expect(existsSync(htmlPath)).toBe(true) + + const html = readFileSync(htmlPath, 'utf-8') + expect(html).toContain('Prerendered slug: 대한민국') + }) + + test('should preserve encoded delimiters in route prerenderParams output paths', () => { + const htmlPath = join( + distDir, + 'prerender-params/reserved%3Fhash%23plus%2B/index.html', + ) + + expect(existsSync(htmlPath)).toBe(true) + + const html = readFileSync(htmlPath, 'utf-8') + expect(html).toContain('Prerendered slug:') + expect(html).toContain('reserved?hash#plus+') + }) + + test('should preserve route prerenderParams search params', () => { + const htmlPath = join(distDir, 'prerender-params/with-query/index.html') + + expect(existsSync(htmlPath)).toBe(true) + + const html = readFileSync(htmlPath, 'utf-8') + expect(html).toContain('Prerendered slug:') + expect(html).toContain('with-query') + expect(html).toContain('Search page: 2') + expect(html).toContain('Search tag: router start') + }) + + test('should strip server-only imports used by prerenderParams from client output', () => { + const htmlPath = join( + distDir, + 'prerender-params/server-only-slug/index.html', + ) + + expect(existsSync(htmlPath)).toBe(true) + expect( + readdirSync(distDir, { recursive: true }).some((relativePath) => { + const filePath = join(distDir, String(relativePath)) + return ( + statSync(filePath).isFile() && + readFileSync(filePath, 'utf-8').includes( + 'server-only-prerender-marker', + ) + ) + }), + ).toBe(false) + }) + + test('should include route sitemap options from prerenderParams', () => { + const sitemapPath = join(distDir, 'sitemap.xml') + + expect(existsSync(sitemapPath)).toBe(true) + + const sitemap = readFileSync(sitemapPath, 'utf-8') + expect(sitemap).toContain( + 'https://example.com/prerender-params/hello-world', + ) + expect(sitemap).toContain('2026-05-05') + expect(sitemap).toContain('0.8') + expect(sitemap).toContain('weekly') + expect(sitemap).toContain( + 'https://example.com/prerender-params/대한민국', + ) + expect(sitemap).toContain('0.6') + expect(sitemap).toContain( + 'https://example.com/prerender-params/with-query?page=2&tag=router+start', + ) + expect(sitemap).toContain('0.4') + expect(sitemap).not.toContain( + 'https://example.com/prerender-params/server-only-slug', + ) + }) }) }) diff --git a/e2e/solid-start/basic/package.json b/e2e/solid-start/basic/package.json index 18089a33a5d..8a2bf0ffae0 100644 --- a/e2e/solid-start/basic/package.json +++ b/e2e/solid-start/basic/package.json @@ -65,6 +65,10 @@ { "toolchain": "rsbuild", "mode": "ssr" + }, + { + "toolchain": "rsbuild", + "mode": "prerender" } ] } diff --git a/e2e/solid-start/basic/rsbuild.config.ts b/e2e/solid-start/basic/rsbuild.config.ts index ea04a3167aa..5067f95ac5a 100644 --- a/e2e/solid-start/basic/rsbuild.config.ts +++ b/e2e/solid-start/basic/rsbuild.config.ts @@ -2,6 +2,25 @@ import { defineConfig } from '@rsbuild/core' import { pluginBabel } from '@rsbuild/plugin-babel' import { pluginSolid } from '@rsbuild/plugin-solid' import { tanstackStart } from '@tanstack/solid-start/plugin/rsbuild' +import { isPrerender } from './tests/utils/isPrerender' + +const prerenderConfiguration = { + enabled: true, + filter: (page: { path: string }) => + ![ + '/this-route-does-not-exist', + '/redirect', + '/i-do-not-exist', + '/not-found', + '/specialChars/search', + '/specialChars/hash', + '/specialChars/malformed', + '/search-params/default', + '/transition', + '/users', + ].some((p) => page.path.includes(p)), + maxRedirects: 100, +} const outDir = process.env.E2E_DIST_DIR ?? 'dist' @@ -11,7 +30,9 @@ export default defineConfig({ include: /\.(?:jsx|tsx)$/, }), pluginSolid(), - tanstackStart(), + tanstackStart({ + prerender: isPrerender ? prerenderConfiguration : undefined, + }), ], output: { distPath: { diff --git a/e2e/solid-start/basic/src/routeTree.gen.ts b/e2e/solid-start/basic/src/routeTree.gen.ts index c9fba32156b..b9d5d042105 100644 --- a/e2e/solid-start/basic/src/routeTree.gen.ts +++ b/e2e/solid-start/basic/src/routeTree.gen.ts @@ -44,6 +44,7 @@ import { Route as RawStreamSsrMultipleRouteImport } from './routes/raw-stream/ss import { Route as RawStreamSsrMixedRouteImport } from './routes/raw-stream/ssr-mixed' import { Route as RawStreamSsrBinaryHintRouteImport } from './routes/raw-stream/ssr-binary-hint' import { Route as RawStreamClientCallRouteImport } from './routes/raw-stream/client-call' +import { Route as PrerenderParamsSlugRouteImport } from './routes/prerender-params.$slug' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' import { Route as NotFoundViaLoaderRouteImport } from './routes/not-found/via-loader' import { Route as NotFoundViaBeforeLoadTargetRootRouteImport } from './routes/not-found/via-beforeLoad-target-root' @@ -69,6 +70,7 @@ import { Route as RedirectTargetServerFnIndexRouteImport } from './routes/redire import { Route as RedirectTargetServerFnViaUseServerFnRouteImport } from './routes/redirect/$target/serverFn/via-useServerFn' import { Route as RedirectTargetServerFnViaLoaderRouteImport } from './routes/redirect/$target/serverFn/via-loader' import { Route as RedirectTargetServerFnViaBeforeLoadRouteImport } from './routes/redirect/$target/serverFn/via-beforeLoad' +import { Route as LayoutLayout2PrerenderNestedSlugRouteImport } from './routes/_layout/_layout-2/prerender-nested.$slug' const UsersRoute = UsersRouteImport.update({ id: '/users', @@ -247,6 +249,11 @@ const RawStreamClientCallRoute = RawStreamClientCallRouteImport.update({ path: '/client-call', getParentRoute: () => RawStreamRoute, } as any) +const PrerenderParamsSlugRoute = PrerenderParamsSlugRouteImport.update({ + id: '/prerender-params/$slug', + path: '/prerender-params/$slug', + getParentRoute: () => rootRouteImport, +} as any) const PostsPostIdRoute = PostsPostIdRouteImport.update({ id: '/$postId', path: '/$postId', @@ -385,6 +392,12 @@ const RedirectTargetServerFnViaBeforeLoadRoute = path: '/serverFn/via-beforeLoad', getParentRoute: () => RedirectTargetRoute, } as any) +const LayoutLayout2PrerenderNestedSlugRoute = + LayoutLayout2PrerenderNestedSlugRouteImport.update({ + id: '/prerender-nested/$slug', + path: '/prerender-nested/$slug', + getParentRoute: () => LayoutLayout2Route, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -408,6 +421,7 @@ export interface FileRoutesByFullPath { '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/prerender-params/$slug': typeof PrerenderParamsSlugRoute '/raw-stream/client-call': typeof RawStreamClientCallRoute '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute @@ -441,6 +455,7 @@ export interface FileRoutesByFullPath { '/transition/count/create-resource': typeof TransitionCountCreateResourceRoute '/transition/typing/create-resource': typeof TransitionTypingCreateResourceRoute '/redirect/$target/': typeof RedirectTargetIndexRoute + '/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute '/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute @@ -463,6 +478,7 @@ export interface FileRoutesByTo { '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/prerender-params/$slug': typeof PrerenderParamsSlugRoute '/raw-stream/client-call': typeof RawStreamClientCallRoute '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute @@ -495,6 +511,7 @@ export interface FileRoutesByTo { '/transition/count/create-resource': typeof TransitionCountCreateResourceRoute '/transition/typing/create-resource': typeof TransitionTypingCreateResourceRoute '/redirect/$target': typeof RedirectTargetIndexRoute + '/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute '/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute @@ -525,6 +542,7 @@ export interface FileRoutesById { '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/prerender-params/$slug': typeof PrerenderParamsSlugRoute '/raw-stream/client-call': typeof RawStreamClientCallRoute '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute @@ -558,6 +576,7 @@ export interface FileRoutesById { '/transition/count/create-resource': typeof TransitionCountCreateResourceRoute '/transition/typing/create-resource': typeof TransitionTypingCreateResourceRoute '/redirect/$target/': typeof RedirectTargetIndexRoute + '/_layout/_layout-2/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute '/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute @@ -587,6 +606,7 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad-target-root' | '/not-found/via-loader' | '/posts/$postId' + | '/prerender-params/$slug' | '/raw-stream/client-call' | '/raw-stream/ssr-binary-hint' | '/raw-stream/ssr-mixed' @@ -620,6 +640,7 @@ export interface FileRouteTypes { | '/transition/count/create-resource' | '/transition/typing/create-resource' | '/redirect/$target/' + | '/prerender-nested/$slug' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' | '/redirect/$target/serverFn/via-useServerFn' @@ -642,6 +663,7 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad-target-root' | '/not-found/via-loader' | '/posts/$postId' + | '/prerender-params/$slug' | '/raw-stream/client-call' | '/raw-stream/ssr-binary-hint' | '/raw-stream/ssr-mixed' @@ -674,6 +696,7 @@ export interface FileRouteTypes { | '/transition/count/create-resource' | '/transition/typing/create-resource' | '/redirect/$target' + | '/prerender-nested/$slug' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' | '/redirect/$target/serverFn/via-useServerFn' @@ -703,6 +726,7 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad-target-root' | '/not-found/via-loader' | '/posts/$postId' + | '/prerender-params/$slug' | '/raw-stream/client-call' | '/raw-stream/ssr-binary-hint' | '/raw-stream/ssr-mixed' @@ -736,6 +760,7 @@ export interface FileRouteTypes { | '/transition/count/create-resource' | '/transition/typing/create-resource' | '/redirect/$target/' + | '/_layout/_layout-2/prerender-nested/$slug' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' | '/redirect/$target/serverFn/via-useServerFn' @@ -759,6 +784,7 @@ export interface RootRouteChildren { UsersRoute: typeof UsersRouteWithChildren ApiUsersRoute: typeof ApiUsersRouteWithChildren MultiCookieRedirectTargetRoute: typeof MultiCookieRedirectTargetRoute + PrerenderParamsSlugRoute: typeof PrerenderParamsSlugRoute RedirectTargetRoute: typeof RedirectTargetRouteWithChildren MultiCookieRedirectIndexRoute: typeof MultiCookieRedirectIndexRoute RedirectIndexRoute: typeof RedirectIndexRoute @@ -1014,6 +1040,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof RawStreamClientCallRouteImport parentRoute: typeof RawStreamRoute } + '/prerender-params/$slug': { + id: '/prerender-params/$slug' + path: '/prerender-params/$slug' + fullPath: '/prerender-params/$slug' + preLoaderRoute: typeof PrerenderParamsSlugRouteImport + parentRoute: typeof rootRouteImport + } '/posts/$postId': { id: '/posts/$postId' path: '/$postId' @@ -1189,6 +1222,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof RedirectTargetServerFnViaBeforeLoadRouteImport parentRoute: typeof RedirectTargetRoute } + '/_layout/_layout-2/prerender-nested/$slug': { + id: '/_layout/_layout-2/prerender-nested/$slug' + path: '/prerender-nested/$slug' + fullPath: '/prerender-nested/$slug' + preLoaderRoute: typeof LayoutLayout2PrerenderNestedSlugRouteImport + parentRoute: typeof LayoutLayout2Route + } } } @@ -1282,11 +1322,13 @@ const SpecialCharsRouteRouteWithChildren = interface LayoutLayout2RouteChildren { LayoutLayout2LayoutARoute: typeof LayoutLayout2LayoutARoute LayoutLayout2LayoutBRoute: typeof LayoutLayout2LayoutBRoute + LayoutLayout2PrerenderNestedSlugRoute: typeof LayoutLayout2PrerenderNestedSlugRoute } const LayoutLayout2RouteChildren: LayoutLayout2RouteChildren = { LayoutLayout2LayoutARoute: LayoutLayout2LayoutARoute, LayoutLayout2LayoutBRoute: LayoutLayout2LayoutBRoute, + LayoutLayout2PrerenderNestedSlugRoute: LayoutLayout2PrerenderNestedSlugRoute, } const LayoutLayout2RouteWithChildren = LayoutLayout2Route._addFileChildren( @@ -1407,6 +1449,7 @@ const rootRouteChildren: RootRouteChildren = { UsersRoute: UsersRouteWithChildren, ApiUsersRoute: ApiUsersRouteWithChildren, MultiCookieRedirectTargetRoute: MultiCookieRedirectTargetRoute, + PrerenderParamsSlugRoute: PrerenderParamsSlugRoute, RedirectTargetRoute: RedirectTargetRouteWithChildren, MultiCookieRedirectIndexRoute: MultiCookieRedirectIndexRoute, RedirectIndexRoute: RedirectIndexRoute, diff --git a/e2e/solid-start/basic/src/routes/-prerender-params.server.ts b/e2e/solid-start/basic/src/routes/-prerender-params.server.ts new file mode 100644 index 00000000000..f0a0f792316 --- /dev/null +++ b/e2e/solid-start/basic/src/routes/-prerender-params.server.ts @@ -0,0 +1,11 @@ +import '@tanstack/solid-start/server-only' + +export const serverOnlyPrerenderMarker = + 'server-only-prerender-marker-should-not-be-in-client' + +export function getServerOnlyPrerenderSlug() { + return serverOnlyPrerenderMarker.replace( + 'server-only-prerender-marker-should-not-be-in-client', + 'server-only-slug', + ) +} diff --git a/e2e/solid-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx b/e2e/solid-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx new file mode 100644 index 00000000000..70b3b4ade8a --- /dev/null +++ b/e2e/solid-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute( + '/_layout/_layout-2/prerender-nested/$slug', +)({ + prerenderParams: () => [{ params: { slug: 'under-layout' } }], + component: RouteComponent, +}) + +function RouteComponent() { + const params = Route.useParams() + + return
Nested prerendered slug: {params().slug}
+} diff --git a/e2e/solid-start/basic/src/routes/prerender-params.$slug.tsx b/e2e/solid-start/basic/src/routes/prerender-params.$slug.tsx new file mode 100644 index 00000000000..1bd2d4060a7 --- /dev/null +++ b/e2e/solid-start/basic/src/routes/prerender-params.$slug.tsx @@ -0,0 +1,60 @@ +import { createFileRoute } from '@tanstack/solid-router' +import z from 'zod' +import { getServerOnlyPrerenderSlug } from './-prerender-params.server' + +export const Route = createFileRoute('/prerender-params/$slug')({ + validateSearch: z.object({ + page: z.number().optional(), + tag: z.string().optional(), + }), + sitemap: { + changefreq: 'weekly', + }, + prerenderParams: () => [ + { + params: { slug: 'hello-world' }, + sitemap: { + lastmod: '2026-05-05', + priority: 0.8, + }, + }, + { + params: { slug: '대한민국' }, + sitemap: { + priority: 0.6, + }, + }, + { + params: { slug: 'reserved?hash#plus+' }, + sitemap: { + exclude: true, + }, + }, + { + params: { slug: 'with-query' }, + search: { page: 2, tag: 'router start' }, + sitemap: { + priority: 0.4, + }, + }, + { + params: { slug: getServerOnlyPrerenderSlug() }, + sitemap: { + exclude: true, + }, + }, + ], + component: RouteComponent, +}) + +function RouteComponent() { + const params = Route.useParams() + const search = Route.useSearch() + + return ( +
+ Prerendered slug: {params().slug}. Search page: {search().page ?? 'none'}. + Search tag: {search().tag ?? 'none'} +
+ ) +} diff --git a/e2e/solid-start/basic/tests/prerendering.spec.ts b/e2e/solid-start/basic/tests/prerendering.spec.ts index ddcf720a4c0..73075a49f1e 100644 --- a/e2e/solid-start/basic/tests/prerendering.spec.ts +++ b/e2e/solid-start/basic/tests/prerendering.spec.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { join } from 'node:path' import { expect } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' @@ -44,5 +44,130 @@ test.describe('Prerender Static Path Discovery', () => { const html = readFileSync(join(distDir, 'posts/index.html'), 'utf-8') expect(html).toContain('Select a post.') }) + + test('should prerender static routes through outlets', () => { + const htmlPath = join(distDir, 'layout-a/index.html') + + expect(existsSync(htmlPath)).toBe(true) + + const html = readFileSync(htmlPath, 'utf-8') + .replaceAll(''', "'") + .replaceAll(''', "'") + .replaceAll(''', "'") + expect(html).toContain("I'm a layout") + expect(html).toContain("I'm a nested layout") + expect(html).toContain("I'm layout A!") + }) + + test('should prerender dynamic routes through nested pathless outlets', () => { + const htmlPath = join( + distDir, + 'prerender-nested/under-layout/index.html', + ) + + expect(existsSync(htmlPath)).toBe(true) + + const html = readFileSync(htmlPath, 'utf-8') + .replaceAll(''', "'") + .replaceAll(''', "'") + .replaceAll(''', "'") + expect(html).toContain("I'm a layout") + expect(html).toContain("I'm a nested layout") + expect(html).toContain('Nested prerendered slug:') + expect(html).toContain('under-layout') + }) + + test('should contain prerendered content from route prerenderParams', () => { + const htmlPath = join(distDir, 'prerender-params/hello-world/index.html') + + expect(existsSync(htmlPath)).toBe(true) + + const html = readFileSync(htmlPath, 'utf-8') + expect(html).toContain('Prerendered slug:') + expect(html).toContain('hello-world') + }) + + test('should support special characters from route prerenderParams', () => { + const htmlPath = join(distDir, 'prerender-params/대한민국/index.html') + + expect(existsSync(htmlPath)).toBe(true) + + const html = readFileSync(htmlPath, 'utf-8') + expect(html).toContain('Prerendered slug:') + expect(html).toContain('대한민국') + }) + + test('should preserve encoded delimiters in route prerenderParams output paths', () => { + const htmlPath = join( + distDir, + 'prerender-params/reserved%3Fhash%23plus%2B/index.html', + ) + + expect(existsSync(htmlPath)).toBe(true) + + const html = readFileSync(htmlPath, 'utf-8') + expect(html).toContain('Prerendered slug:') + expect(html).toContain('reserved?hash#plus+') + }) + + test('should preserve route prerenderParams search params', () => { + const htmlPath = join(distDir, 'prerender-params/with-query/index.html') + + expect(existsSync(htmlPath)).toBe(true) + + const html = readFileSync(htmlPath, 'utf-8') + expect(html).toContain('Prerendered slug:') + expect(html).toContain('with-query') + expect(html).toContain('Search page:') + expect(html).toContain('2') + expect(html).toContain('Search tag:') + expect(html).toContain('router start') + }) + + test('should strip server-only imports used by prerenderParams from client output', () => { + const htmlPath = join( + distDir, + 'prerender-params/server-only-slug/index.html', + ) + + expect(existsSync(htmlPath)).toBe(true) + expect( + readdirSync(distDir, { recursive: true }).some((relativePath) => { + const filePath = join(distDir, String(relativePath)) + return ( + statSync(filePath).isFile() && + readFileSync(filePath, 'utf-8').includes( + 'server-only-prerender-marker', + ) + ) + }), + ).toBe(false) + }) + + test('should include route sitemap options from prerenderParams', () => { + const sitemapPath = join(distDir, 'sitemap.xml') + + test.skip(!existsSync(sitemapPath), 'Skipping since sitemap is disabled') + expect(existsSync(sitemapPath)).toBe(true) + + const sitemap = readFileSync(sitemapPath, 'utf-8') + expect(sitemap).toContain( + 'https://example.com/prerender-params/hello-world', + ) + expect(sitemap).toContain('2026-05-05') + expect(sitemap).toContain('0.8') + expect(sitemap).toContain('weekly') + expect(sitemap).toContain( + 'https://example.com/prerender-params/대한민국', + ) + expect(sitemap).toContain('0.6') + expect(sitemap).toContain( + 'https://example.com/prerender-params/with-query?page=2&tag=router+start', + ) + expect(sitemap).toContain('0.4') + expect(sitemap).not.toContain( + 'https://example.com/prerender-params/server-only-slug', + ) + }) }) }) diff --git a/e2e/solid-start/basic/vite.config.ts b/e2e/solid-start/basic/vite.config.ts index 9e26483d040..c8128fb92f5 100644 --- a/e2e/solid-start/basic/vite.config.ts +++ b/e2e/solid-start/basic/vite.config.ts @@ -45,6 +45,12 @@ export default defineConfig({ tanstackStart({ spa: isSpaMode ? spaModeConfiguration : undefined, prerender: isPrerender ? prerenderConfiguration : undefined, + sitemap: isPrerender + ? { + enabled: true, + host: 'https://example.com', + } + : undefined, }), viteSolid({ ssr: true }), ], diff --git a/e2e/vue-start/basic/package.json b/e2e/vue-start/basic/package.json index e1fc89db169..900ace5860b 100644 --- a/e2e/vue-start/basic/package.json +++ b/e2e/vue-start/basic/package.json @@ -67,6 +67,10 @@ { "toolchain": "rsbuild", "mode": "ssr" + }, + { + "toolchain": "rsbuild", + "mode": "prerender" } ] } diff --git a/e2e/vue-start/basic/rsbuild.config.ts b/e2e/vue-start/basic/rsbuild.config.ts index 93af798c3c6..fc8ff3b53ff 100644 --- a/e2e/vue-start/basic/rsbuild.config.ts +++ b/e2e/vue-start/basic/rsbuild.config.ts @@ -5,6 +5,24 @@ import { pluginVueJsx } from '@rsbuild/plugin-vue-jsx' import { tanstackStart } from '@tanstack/vue-start/plugin/rsbuild' import { isPrerender } from './tests/utils/isPrerender' +const prerenderConfiguration = { + enabled: true, + filter: (page: { path: string }) => + ![ + '/this-route-does-not-exist', + '/redirect', + '/i-do-not-exist', + '/not-found', + '/specialChars/search', + '/specialChars/hash', + '/specialChars/malformed', + '/search-params', // search-param routes have dynamic content based on query params + '/transition', + '/users', + ].some((p) => page.path.includes(p)), + maxRedirects: 100, +} + const outDir = process.env.E2E_DIST_DIR ?? 'dist' export default defineConfig({ @@ -14,7 +32,9 @@ export default defineConfig({ }), pluginVue(), pluginVueJsx(), - tanstackStart(), + tanstackStart({ + prerender: isPrerender ? prerenderConfiguration : undefined, + }), ], performance: { chunkSplit: { diff --git a/e2e/vue-start/basic/src/routeTree.gen.ts b/e2e/vue-start/basic/src/routeTree.gen.ts index 016a292835b..bd680185e89 100644 --- a/e2e/vue-start/basic/src/routeTree.gen.ts +++ b/e2e/vue-start/basic/src/routeTree.gen.ts @@ -43,6 +43,7 @@ import { Route as RawStreamSsrMultipleRouteImport } from './routes/raw-stream/ss import { Route as RawStreamSsrMixedRouteImport } from './routes/raw-stream/ssr-mixed' import { Route as RawStreamSsrBinaryHintRouteImport } from './routes/raw-stream/ssr-binary-hint' import { Route as RawStreamClientCallRouteImport } from './routes/raw-stream/client-call' +import { Route as PrerenderParamsSlugRouteImport } from './routes/prerender-params.$slug' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' import { Route as NotFoundViaLoaderRouteImport } from './routes/not-found/via-loader' import { Route as NotFoundViaBeforeLoadTargetRootRouteImport } from './routes/not-found/via-beforeLoad-target-root' @@ -66,6 +67,7 @@ import { Route as RedirectTargetServerFnIndexRouteImport } from './routes/redire import { Route as RedirectTargetServerFnViaUseServerFnRouteImport } from './routes/redirect/$target/serverFn/via-useServerFn' import { Route as RedirectTargetServerFnViaLoaderRouteImport } from './routes/redirect/$target/serverFn/via-loader' import { Route as RedirectTargetServerFnViaBeforeLoadRouteImport } from './routes/redirect/$target/serverFn/via-beforeLoad' +import { Route as LayoutLayout2PrerenderNestedSlugRouteImport } from './routes/_layout/_layout-2/prerender-nested.$slug' const UsersRoute = UsersRouteImport.update({ id: '/users', @@ -239,6 +241,11 @@ const RawStreamClientCallRoute = RawStreamClientCallRouteImport.update({ path: '/client-call', getParentRoute: () => RawStreamRoute, } as any) +const PrerenderParamsSlugRoute = PrerenderParamsSlugRouteImport.update({ + id: '/prerender-params/$slug', + path: '/prerender-params/$slug', + getParentRoute: () => rootRouteImport, +} as any) const PostsPostIdRoute = PostsPostIdRouteImport.update({ id: '/$postId', path: '/$postId', @@ -365,6 +372,12 @@ const RedirectTargetServerFnViaBeforeLoadRoute = path: '/serverFn/via-beforeLoad', getParentRoute: () => RedirectTargetRoute, } as any) +const LayoutLayout2PrerenderNestedSlugRoute = + LayoutLayout2PrerenderNestedSlugRouteImport.update({ + id: '/prerender-nested/$slug', + path: '/prerender-nested/$slug', + getParentRoute: () => LayoutLayout2Route, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -387,6 +400,7 @@ export interface FileRoutesByFullPath { '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/prerender-params/$slug': typeof PrerenderParamsSlugRoute '/raw-stream/client-call': typeof RawStreamClientCallRoute '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute @@ -418,6 +432,7 @@ export interface FileRoutesByFullPath { '/specialChars/malformed/$param': typeof SpecialCharsMalformedParamRoute '/specialChars/malformed/search': typeof SpecialCharsMalformedSearchRoute '/redirect/$target/': typeof RedirectTargetIndexRoute + '/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute '/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute @@ -439,6 +454,7 @@ export interface FileRoutesByTo { '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/prerender-params/$slug': typeof PrerenderParamsSlugRoute '/raw-stream/client-call': typeof RawStreamClientCallRoute '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute @@ -469,6 +485,7 @@ export interface FileRoutesByTo { '/specialChars/malformed/$param': typeof SpecialCharsMalformedParamRoute '/specialChars/malformed/search': typeof SpecialCharsMalformedSearchRoute '/redirect/$target': typeof RedirectTargetIndexRoute + '/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute '/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute @@ -498,6 +515,7 @@ export interface FileRoutesById { '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/prerender-params/$slug': typeof PrerenderParamsSlugRoute '/raw-stream/client-call': typeof RawStreamClientCallRoute '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute @@ -529,6 +547,7 @@ export interface FileRoutesById { '/specialChars/malformed/$param': typeof SpecialCharsMalformedParamRoute '/specialChars/malformed/search': typeof SpecialCharsMalformedSearchRoute '/redirect/$target/': typeof RedirectTargetIndexRoute + '/_layout/_layout-2/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute '/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute @@ -557,6 +576,7 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad-target-root' | '/not-found/via-loader' | '/posts/$postId' + | '/prerender-params/$slug' | '/raw-stream/client-call' | '/raw-stream/ssr-binary-hint' | '/raw-stream/ssr-mixed' @@ -588,6 +608,7 @@ export interface FileRouteTypes { | '/specialChars/malformed/$param' | '/specialChars/malformed/search' | '/redirect/$target/' + | '/prerender-nested/$slug' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' | '/redirect/$target/serverFn/via-useServerFn' @@ -609,6 +630,7 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad-target-root' | '/not-found/via-loader' | '/posts/$postId' + | '/prerender-params/$slug' | '/raw-stream/client-call' | '/raw-stream/ssr-binary-hint' | '/raw-stream/ssr-mixed' @@ -639,6 +661,7 @@ export interface FileRouteTypes { | '/specialChars/malformed/$param' | '/specialChars/malformed/search' | '/redirect/$target' + | '/prerender-nested/$slug' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' | '/redirect/$target/serverFn/via-useServerFn' @@ -667,6 +690,7 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad-target-root' | '/not-found/via-loader' | '/posts/$postId' + | '/prerender-params/$slug' | '/raw-stream/client-call' | '/raw-stream/ssr-binary-hint' | '/raw-stream/ssr-mixed' @@ -698,6 +722,7 @@ export interface FileRouteTypes { | '/specialChars/malformed/$param' | '/specialChars/malformed/search' | '/redirect/$target/' + | '/_layout/_layout-2/prerender-nested/$slug' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' | '/redirect/$target/serverFn/via-useServerFn' @@ -720,6 +745,7 @@ export interface RootRouteChildren { UsersRoute: typeof UsersRouteWithChildren ApiUsersRoute: typeof ApiUsersRouteWithChildren MultiCookieRedirectTargetRoute: typeof MultiCookieRedirectTargetRoute + PrerenderParamsSlugRoute: typeof PrerenderParamsSlugRoute RedirectTargetRoute: typeof RedirectTargetRouteWithChildren MultiCookieRedirectIndexRoute: typeof MultiCookieRedirectIndexRoute RedirectIndexRoute: typeof RedirectIndexRoute @@ -966,6 +992,13 @@ declare module '@tanstack/vue-router' { preLoaderRoute: typeof RawStreamClientCallRouteImport parentRoute: typeof RawStreamRoute } + '/prerender-params/$slug': { + id: '/prerender-params/$slug' + path: '/prerender-params/$slug' + fullPath: '/prerender-params/$slug' + preLoaderRoute: typeof PrerenderParamsSlugRouteImport + parentRoute: typeof rootRouteImport + } '/posts/$postId': { id: '/posts/$postId' path: '/$postId' @@ -1127,6 +1160,13 @@ declare module '@tanstack/vue-router' { preLoaderRoute: typeof RedirectTargetServerFnViaBeforeLoadRouteImport parentRoute: typeof RedirectTargetRoute } + '/_layout/_layout-2/prerender-nested/$slug': { + id: '/_layout/_layout-2/prerender-nested/$slug' + path: '/prerender-nested/$slug' + fullPath: '/prerender-nested/$slug' + preLoaderRoute: typeof LayoutLayout2PrerenderNestedSlugRouteImport + parentRoute: typeof LayoutLayout2Route + } } } @@ -1220,11 +1260,13 @@ const SpecialCharsRouteRouteWithChildren = interface LayoutLayout2RouteChildren { LayoutLayout2LayoutARoute: typeof LayoutLayout2LayoutARoute LayoutLayout2LayoutBRoute: typeof LayoutLayout2LayoutBRoute + LayoutLayout2PrerenderNestedSlugRoute: typeof LayoutLayout2PrerenderNestedSlugRoute } const LayoutLayout2RouteChildren: LayoutLayout2RouteChildren = { LayoutLayout2LayoutARoute: LayoutLayout2LayoutARoute, LayoutLayout2LayoutBRoute: LayoutLayout2LayoutBRoute, + LayoutLayout2PrerenderNestedSlugRoute: LayoutLayout2PrerenderNestedSlugRoute, } const LayoutLayout2RouteWithChildren = LayoutLayout2Route._addFileChildren( @@ -1344,6 +1386,7 @@ const rootRouteChildren: RootRouteChildren = { UsersRoute: UsersRouteWithChildren, ApiUsersRoute: ApiUsersRouteWithChildren, MultiCookieRedirectTargetRoute: MultiCookieRedirectTargetRoute, + PrerenderParamsSlugRoute: PrerenderParamsSlugRoute, RedirectTargetRoute: RedirectTargetRouteWithChildren, MultiCookieRedirectIndexRoute: MultiCookieRedirectIndexRoute, RedirectIndexRoute: RedirectIndexRoute, diff --git a/e2e/vue-start/basic/src/routes/-prerender-params.server.ts b/e2e/vue-start/basic/src/routes/-prerender-params.server.ts new file mode 100644 index 00000000000..6eb138da4b8 --- /dev/null +++ b/e2e/vue-start/basic/src/routes/-prerender-params.server.ts @@ -0,0 +1,11 @@ +import '@tanstack/vue-start/server-only' + +export const serverOnlyPrerenderMarker = + 'server-only-prerender-marker-should-not-be-in-client' + +export function getServerOnlyPrerenderSlug() { + return serverOnlyPrerenderMarker.replace( + 'server-only-prerender-marker-should-not-be-in-client', + 'server-only-slug', + ) +} diff --git a/e2e/vue-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx b/e2e/vue-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx new file mode 100644 index 00000000000..0f698bd76eb --- /dev/null +++ b/e2e/vue-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute( + '/_layout/_layout-2/prerender-nested/$slug', +)({ + prerenderParams: () => [{ params: { slug: 'under-layout' } }], + component: RouteComponent, +}) + +function RouteComponent() { + const params = Route.useParams() + + return
Nested prerendered slug: {params.value.slug}
+} diff --git a/e2e/vue-start/basic/src/routes/prerender-params.$slug.tsx b/e2e/vue-start/basic/src/routes/prerender-params.$slug.tsx new file mode 100644 index 00000000000..43f56e23a50 --- /dev/null +++ b/e2e/vue-start/basic/src/routes/prerender-params.$slug.tsx @@ -0,0 +1,60 @@ +import { createFileRoute } from '@tanstack/vue-router' +import z from 'zod' +import { getServerOnlyPrerenderSlug } from './-prerender-params.server' + +export const Route = createFileRoute('/prerender-params/$slug')({ + validateSearch: z.object({ + page: z.number().optional(), + tag: z.string().optional(), + }), + sitemap: { + changefreq: 'weekly', + }, + prerenderParams: () => [ + { + params: { slug: 'hello-world' }, + sitemap: { + lastmod: '2026-05-05', + priority: 0.8, + }, + }, + { + params: { slug: '대한민국' }, + sitemap: { + priority: 0.6, + }, + }, + { + params: { slug: 'reserved?hash#plus+' }, + sitemap: { + exclude: true, + }, + }, + { + params: { slug: 'with-query' }, + search: { page: 2, tag: 'router start' }, + sitemap: { + priority: 0.4, + }, + }, + { + params: { slug: getServerOnlyPrerenderSlug() }, + sitemap: { + exclude: true, + }, + }, + ], + component: RouteComponent, +}) + +function RouteComponent() { + const params = Route.useParams() + const search = Route.useSearch() + + return ( +
+ Prerendered slug: {params.value.slug}. Search page:{' '} + {search.value.page ?? 'none'}. Search tag: {search.value.tag ?? 'none'} +
+ ) +} diff --git a/e2e/vue-start/basic/tests/prerendering.spec.ts b/e2e/vue-start/basic/tests/prerendering.spec.ts index ddcf720a4c0..73075a49f1e 100644 --- a/e2e/vue-start/basic/tests/prerendering.spec.ts +++ b/e2e/vue-start/basic/tests/prerendering.spec.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { join } from 'node:path' import { expect } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' @@ -44,5 +44,130 @@ test.describe('Prerender Static Path Discovery', () => { const html = readFileSync(join(distDir, 'posts/index.html'), 'utf-8') expect(html).toContain('Select a post.') }) + + test('should prerender static routes through outlets', () => { + const htmlPath = join(distDir, 'layout-a/index.html') + + expect(existsSync(htmlPath)).toBe(true) + + const html = readFileSync(htmlPath, 'utf-8') + .replaceAll(''', "'") + .replaceAll(''', "'") + .replaceAll(''', "'") + expect(html).toContain("I'm a layout") + expect(html).toContain("I'm a nested layout") + expect(html).toContain("I'm layout A!") + }) + + test('should prerender dynamic routes through nested pathless outlets', () => { + const htmlPath = join( + distDir, + 'prerender-nested/under-layout/index.html', + ) + + expect(existsSync(htmlPath)).toBe(true) + + const html = readFileSync(htmlPath, 'utf-8') + .replaceAll(''', "'") + .replaceAll(''', "'") + .replaceAll(''', "'") + expect(html).toContain("I'm a layout") + expect(html).toContain("I'm a nested layout") + expect(html).toContain('Nested prerendered slug:') + expect(html).toContain('under-layout') + }) + + test('should contain prerendered content from route prerenderParams', () => { + const htmlPath = join(distDir, 'prerender-params/hello-world/index.html') + + expect(existsSync(htmlPath)).toBe(true) + + const html = readFileSync(htmlPath, 'utf-8') + expect(html).toContain('Prerendered slug:') + expect(html).toContain('hello-world') + }) + + test('should support special characters from route prerenderParams', () => { + const htmlPath = join(distDir, 'prerender-params/대한민국/index.html') + + expect(existsSync(htmlPath)).toBe(true) + + const html = readFileSync(htmlPath, 'utf-8') + expect(html).toContain('Prerendered slug:') + expect(html).toContain('대한민국') + }) + + test('should preserve encoded delimiters in route prerenderParams output paths', () => { + const htmlPath = join( + distDir, + 'prerender-params/reserved%3Fhash%23plus%2B/index.html', + ) + + expect(existsSync(htmlPath)).toBe(true) + + const html = readFileSync(htmlPath, 'utf-8') + expect(html).toContain('Prerendered slug:') + expect(html).toContain('reserved?hash#plus+') + }) + + test('should preserve route prerenderParams search params', () => { + const htmlPath = join(distDir, 'prerender-params/with-query/index.html') + + expect(existsSync(htmlPath)).toBe(true) + + const html = readFileSync(htmlPath, 'utf-8') + expect(html).toContain('Prerendered slug:') + expect(html).toContain('with-query') + expect(html).toContain('Search page:') + expect(html).toContain('2') + expect(html).toContain('Search tag:') + expect(html).toContain('router start') + }) + + test('should strip server-only imports used by prerenderParams from client output', () => { + const htmlPath = join( + distDir, + 'prerender-params/server-only-slug/index.html', + ) + + expect(existsSync(htmlPath)).toBe(true) + expect( + readdirSync(distDir, { recursive: true }).some((relativePath) => { + const filePath = join(distDir, String(relativePath)) + return ( + statSync(filePath).isFile() && + readFileSync(filePath, 'utf-8').includes( + 'server-only-prerender-marker', + ) + ) + }), + ).toBe(false) + }) + + test('should include route sitemap options from prerenderParams', () => { + const sitemapPath = join(distDir, 'sitemap.xml') + + test.skip(!existsSync(sitemapPath), 'Skipping since sitemap is disabled') + expect(existsSync(sitemapPath)).toBe(true) + + const sitemap = readFileSync(sitemapPath, 'utf-8') + expect(sitemap).toContain( + 'https://example.com/prerender-params/hello-world', + ) + expect(sitemap).toContain('2026-05-05') + expect(sitemap).toContain('0.8') + expect(sitemap).toContain('weekly') + expect(sitemap).toContain( + 'https://example.com/prerender-params/대한민국', + ) + expect(sitemap).toContain('0.6') + expect(sitemap).toContain( + 'https://example.com/prerender-params/with-query?page=2&tag=router+start', + ) + expect(sitemap).toContain('0.4') + expect(sitemap).not.toContain( + 'https://example.com/prerender-params/server-only-slug', + ) + }) }) }) diff --git a/e2e/vue-start/basic/vite.config.ts b/e2e/vue-start/basic/vite.config.ts index 6fe8d5d1948..746ab47996b 100644 --- a/e2e/vue-start/basic/vite.config.ts +++ b/e2e/vue-start/basic/vite.config.ts @@ -48,6 +48,12 @@ export default defineConfig({ tanstackStart({ spa: isSpaMode ? spaModeConfiguration : undefined, prerender: isPrerender ? prerenderConfiguration : undefined, + sitemap: isPrerender + ? { + enabled: true, + host: 'https://example.com', + } + : undefined, }), vueJsx(), ], diff --git a/packages/start-client-core/src/index.tsx b/packages/start-client-core/src/index.tsx index f7835c98592..2162a3e8152 100644 --- a/packages/start-client-core/src/index.tsx +++ b/packages/start-client-core/src/index.tsx @@ -105,6 +105,7 @@ export type { } from './constants' export type * from './serverRoute' +export type * from './prerenderParams' export type * from './startEntry' diff --git a/packages/start-client-core/src/prerenderParams.ts b/packages/start-client-core/src/prerenderParams.ts new file mode 100644 index 00000000000..bbb888c2562 --- /dev/null +++ b/packages/start-client-core/src/prerenderParams.ts @@ -0,0 +1,108 @@ +import type { + AnyContext, + AnyRoute, + Awaitable, + Expand, + ResolveAllParamsFromParent, + ResolveFullSearchSchemaInput, +} from '@tanstack/router-core' + +declare module '@tanstack/router-core' { + /* eslint-disable unused-imports/no-unused-vars */ + interface FilebaseRouteOptionsInterface< + TRegister, + TParentRoute extends AnyRoute = AnyRoute, + TId extends string = string, + TPath extends string = string, + TSearchValidator = undefined, + TParams = {}, + TLoaderDeps extends Record = {}, + TLoaderFn = undefined, + TRouterContext = {}, + TRouteContextFn = AnyContext, + TBeforeLoadFn = AnyContext, + TRemountDepsFn = AnyContext, + TSSR = unknown, + TServerMiddlewares = unknown, + THandlers = undefined, + > { + prerenderParams?: ( + ctx: PrerenderParamsContext, + ) => Awaitable< + ReadonlyArray< + PrerenderParamsEntry< + Expand>, + Expand> + > + > + > + sitemap?: RouteSitemapOptions + } + /* eslint-enable unused-imports/no-unused-vars */ +} + +export interface PrerenderParamsContext { + routePath: TPath + signal: AbortSignal +} + +type PrerenderParamsSearch = unknown extends TSearch + ? { search?: Record } + : {} extends TSearch + ? { search?: Expand } + : { search: Expand } + +export type PrerenderParamsEntry = { + params: TParams + sitemap?: RouteSitemapOptions + prerender?: RoutePrerenderOptions +} & PrerenderParamsSearch + +export interface RouteSitemapOptions { + exclude?: boolean + priority?: number + changefreq?: + | 'always' + | 'hourly' + | 'daily' + | 'weekly' + | 'monthly' + | 'yearly' + | 'never' + lastmod?: string | Date + alternateRefs?: Array<{ + href: string + hreflang: string + }> + images?: Array<{ + loc: string + caption?: string + title?: string + }> + news?: { + publication: { + name: string + language: string + } + publicationDate: string | Date + title: string + } +} + +export interface RoutePrerenderOptions { + enabled?: boolean + outputPath?: string + autoSubfolderIndex?: boolean + crawlLinks?: boolean + retryCount?: number + retryDelay?: number + onSuccess?: (opts: { + page: { + path: string + sitemap?: RouteSitemapOptions + fromCrawl?: boolean + } + html: string + }) => any + headers?: Record +} diff --git a/packages/start-client-core/src/tests/prerenderParams.test-d.ts b/packages/start-client-core/src/tests/prerenderParams.test-d.ts new file mode 100644 index 00000000000..b16df68f314 --- /dev/null +++ b/packages/start-client-core/src/tests/prerenderParams.test-d.ts @@ -0,0 +1,226 @@ +import { expectTypeOf, test } from 'vitest' +import type { AnyRoute, FileBaseRouteOptions } from '@tanstack/router-core' + +type ParentRoute = Omit & { + types: Omit & { + allParams: { + orgId: string + } + } +} + +test('prerenderParams uses route path and all params', () => { + const options = { + prerenderParams: (ctx) => { + expectTypeOf(ctx.routePath).toEqualTypeOf<'/posts/$slug'>() + expectTypeOf(ctx.signal).toEqualTypeOf() + + return [ + { + params: { + orgId: 'tanstack', + slug: 'hello-world', + }, + }, + ] + }, + sitemap: { + priority: 0.7, + changefreq: 'weekly', + }, + } satisfies FileBaseRouteOptions< + unknown, + ParentRoute, + string, + '/posts/$slug', + undefined, + { slug: string } + > + + expectTypeOf(options.sitemap.changefreq).toEqualTypeOf<'weekly'>() + + type Entry = Awaited< + ReturnType> + >[number] + + expectTypeOf().toEqualTypeOf<{ + orgId: string + slug: string + }>() +}) + +test('prerenderParams requires parent and route params', () => { + const options = { + // @ts-expect-error orgId is inherited from the parent route and required + prerenderParams: () => [ + { + params: { + slug: 'hello-world', + }, + }, + ], + } satisfies FileBaseRouteOptions< + unknown, + ParentRoute, + string, + '/posts/$slug', + undefined, + { slug: string } + > + + expectTypeOf(options).toEqualTypeOf() +}) + +test('prerenderParams supports multiple params, optional params, and splats', () => { + const multipleParams = { + prerenderParams: () => [ + { + params: { + category: 'guides', + slug: 'routing', + }, + }, + ], + } satisfies FileBaseRouteOptions< + unknown, + AnyRoute, + string, + '/posts/$category/$slug', + undefined, + { category: string; slug: string } + > + + type MultipleParamsEntry = Awaited< + ReturnType> + >[number] + + expectTypeOf().toEqualTypeOf<{ + category: string + slug: string + }>() + expectTypeOf(multipleParams).toEqualTypeOf() + + const optionalParams = { + prerenderParams: () => [ + { + params: {}, + }, + { + params: { + category: 'guides', + }, + }, + ], + } satisfies FileBaseRouteOptions< + unknown, + AnyRoute, + string, + '/posts/{-$category}/{-$slug}', + undefined, + { category?: string; slug?: string } + > + + type OptionalParamsEntry = Awaited< + ReturnType> + >[number] + + expectTypeOf().toMatchTypeOf<{ + category?: string + slug?: string + }>() + expectTypeOf(optionalParams).toEqualTypeOf() + + const splatParams = { + prerenderParams: () => [ + { + params: { + _splat: 'docs/routing', + }, + }, + ], + } satisfies FileBaseRouteOptions< + unknown, + AnyRoute, + string, + '/files/$', + undefined, + { _splat: string } + > + + type SplatParamsEntry = Awaited< + ReturnType> + >[number] + + expectTypeOf().toEqualTypeOf<{ + _splat: string + }>() + expectTypeOf(splatParams).toEqualTypeOf() +}) + +test('prerenderParams infers and requires search params', () => { + type ParentSearchRoute = Omit & { + types: Omit & { + fullSearchSchemaInput: { + locale?: string + } + } + } + + type SearchValidator = (input: { page: number; tag?: string }) => { + page: number + tag?: string + } + + const options = { + prerenderParams: () => [ + { + params: { + slug: 'hello-world', + }, + search: { + locale: 'en', + page: 2, + tag: 'router', + }, + }, + ], + } satisfies FileBaseRouteOptions< + unknown, + ParentSearchRoute, + string, + '/posts/$slug', + SearchValidator, + { slug: string } + > + + type Entry = Awaited< + ReturnType> + >[number] + + expectTypeOf().toMatchTypeOf<{ + locale?: string + page: number + tag?: string + }>() + expectTypeOf(options).toEqualTypeOf() + + const missingSearch = { + // @ts-expect-error page is required by the route search schema + prerenderParams: () => [ + { + params: { + slug: 'hello-world', + }, + }, + ], + } satisfies FileBaseRouteOptions< + unknown, + ParentSearchRoute, + string, + '/posts/$slug', + SearchValidator, + { slug: string } + > + + expectTypeOf(missingSearch).toEqualTypeOf() +}) diff --git a/packages/start-plugin-core/src/build-sitemap.ts b/packages/start-plugin-core/src/build-sitemap.ts index 3eae17ca021..200aca496d8 100644 --- a/packages/start-plugin-core/src/build-sitemap.ts +++ b/packages/start-plugin-core/src/build-sitemap.ts @@ -204,6 +204,8 @@ function createXml(elementName: 'urlset' | 'sitemapindex'): XMLBuilder { .ele(elementName, { xmlns: 'https://www.sitemaps.org/schemas/sitemap/0.9', 'xmlns:xhtml': 'http://www.w3.org/1999/xhtml', + 'xmlns:image': 'http://www.google.com/schemas/sitemap-image/1.1', + 'xmlns:news': 'http://www.google.com/schemas/sitemap-news/0.9', }) .com(`This file was automatically generated by TanStack Start.`) } diff --git a/packages/start-plugin-core/src/global.d.ts b/packages/start-plugin-core/src/global.d.ts index c974e5019e8..e6f6f407f1e 100644 --- a/packages/start-plugin-core/src/global.d.ts +++ b/packages/start-plugin-core/src/global.d.ts @@ -1,3 +1,5 @@ +import type { AnyRoute } from '@tanstack/router-core' + /* eslint-disable no-var */ declare global { var TSS_ROUTES_MANIFEST: Record< @@ -8,5 +10,14 @@ declare global { } > var TSS_PRERENDABLE_PATHS: Array<{ path: string }> | undefined + var TSS_PRERENDER_DYNAMIC_ROUTES: + | Array<{ + path: string + routePath: string + }> + | undefined + var TSS_PRERENDER_ROUTE_TREE: + | (() => Promise) + | undefined } export {} diff --git a/packages/start-plugin-core/src/post-build.ts b/packages/start-plugin-core/src/post-build.ts index 966cdd38cca..336a7751cac 100644 --- a/packages/start-plugin-core/src/post-build.ts +++ b/packages/start-plugin-core/src/post-build.ts @@ -19,9 +19,8 @@ export async function postBuild({ ...startConfig.prerender, enabled: startConfig.prerender?.enabled ?? - startConfig.pages.some((d) => - typeof d === 'string' ? false : !!d.prerender?.enabled, - ), + (startConfig.pages.some((page) => page.prerender?.enabled) || + !!globalThis.TSS_PRERENDER_DYNAMIC_ROUTES?.length), } } diff --git a/packages/start-plugin-core/src/prerender-params-runner.ts b/packages/start-plugin-core/src/prerender-params-runner.ts new file mode 100644 index 00000000000..f50f4a444c8 --- /dev/null +++ b/packages/start-plugin-core/src/prerender-params-runner.ts @@ -0,0 +1,215 @@ +import { defaultStringifySearch, interpolatePath } from '@tanstack/router-core' +import { collectPrerenderRouteOptions } from './prerender-route-options' +import type { Page } from './schema' +import type { + RoutePrerenderOptions, + RouteSitemapOptions, +} from '@tanstack/start-client-core' +import type { + PrerenderRouteMetadata, + PrerenderRouteOptions, +} from './prerender-route-options' +import type { AnyRoute } from '@tanstack/router-core' + +interface PrerenderParamsLogger { + warn: (...args: Array) => void +} + +export interface RunPrerenderParamsOptions { + routeTree: AnyRoute | undefined + pages: Array + logger: PrerenderParamsLogger + filter?: (page: Page) => unknown + prerenderParamsTimeout?: number +} + +export async function runPrerenderParams({ + routeTree, + pages, + logger, + filter, + prerenderParamsTimeout, +}: RunPrerenderParamsOptions): Promise> { + const { routeOptions, dynamicRoutes, sitemapRoutes } = + collectPrerenderRouteOptions(routeTree) + const pagesByPath = new Map(pages.map((page) => [page.path, page])) + + for (const route of sitemapRoutes) { + const options = routeOptions.get(route.routePath) + if (!options?.sitemap) continue + + const page = pagesByPath.get(route.path) + if (!page || dynamic(route.path)) continue + + pagesByPath.set(route.path, merge(page, { sitemap: options.sitemap })) + } + + const controller = new AbortController() + const cleanupProcessAbort = signals(controller) + + try { + for (const route of dynamicRoutes) { + const options = routeOptions.get(route.routePath) + if (!options?.prerenderParams) continue + + if (!dynamic(route.path)) { + logger.warn( + `Skipping prerenderParams for static route ${route.routePath}; static routes are already discovered automatically.`, + ) + continue + } + + const cleanupTimeout = timeout( + controller, + prerenderParamsTimeout, + route.routePath, + ) + + const entries = await call( + () => + options.prerenderParams!({ + routePath: route.routePath, + signal: controller.signal, + }), + controller.signal, + ).finally(cleanupTimeout) + + for (const entry of entries) { + const page = create(route, options, entry) + + if (filter && !filter(page)) { + continue + } + + const existing = pagesByPath.get(page.path) + // Explicit pages, or the first generated entry for a duplicate path, + // keep precedence over later prerenderParams entries. + pagesByPath.set(page.path, existing ? merge(page, existing) : page) + } + } + } finally { + cleanupProcessAbort() + } + + return Array.from(pagesByPath.values()) +} + +function signals(controller: AbortController) { + const abort = () => controller.abort() + + process.once('SIGINT', abort) + process.once('SIGTERM', abort) + + return () => { + process.off('SIGINT', abort) + process.off('SIGTERM', abort) + } +} + +function timeout( + controller: AbortController, + timeout: number | undefined, + routePath: string, +) { + if (timeout === undefined) { + return () => {} + } + + const timeoutId = setTimeout(() => { + controller.abort( + new Error(`prerenderParams for route ${routePath} timed out`), + ) + }, timeout) + + return () => clearTimeout(timeoutId) +} + +async function call( + callback: () => T | Promise, + signal: AbortSignal, +): Promise { + if (signal.aborted) { + throw signal.reason ?? new Error('prerenderParams aborted') + } + + return await new Promise((resolve, reject) => { + const abort = () => + reject(signal.reason ?? new Error('prerenderParams aborted')) + signal.addEventListener('abort', abort, { once: true }) + + Promise.resolve() + .then(callback) + .then(resolve, reject) + .finally(() => { + signal.removeEventListener('abort', abort) + }) + }) +} + +function create( + route: PrerenderRouteMetadata, + options: PrerenderRouteOptions, + entry: { + params: Record + search?: Record + sitemap?: RouteSitemapOptions + prerender?: RoutePrerenderOptions + }, +): Page { + const { interpolatedPath, isMissingParams, usedParams } = interpolatePath({ + path: route.path, + params: entry.params, + }) + + if ( + isMissingParams || + Object.entries(usedParams).some( + ([key, value]) => key !== '*' && value == null, + ) + ) { + throw new Error( + `Missing prerenderParams values for route ${route.routePath}`, + ) + } + + return { + path: interpolatedPath + search(entry.search), + sitemap: sitemap(options.sitemap, entry.sitemap), + prerender: entry.prerender, + } +} + +function search(value: Record | undefined) { + return value ? defaultStringifySearch(value) : '' +} + +function merge(base: Page, override: Partial): Page { + return { + ...base, + ...override, + sitemap: sitemap(base.sitemap, override.sitemap), + prerender: prerender(base.prerender, override.prerender), + } +} + +function sitemap( + base: RouteSitemapOptions | undefined, + override: RouteSitemapOptions | undefined, +) { + if (!base) return override + if (!override) return base + return { ...base, ...override } +} + +function prerender( + base: RoutePrerenderOptions | undefined, + override: RoutePrerenderOptions | undefined, +) { + if (!base) return override + if (!override) return base + return { ...base, ...override } +} + +function dynamic(path: string) { + return path.includes('$') +} diff --git a/packages/start-plugin-core/src/prerender-route-options.ts b/packages/start-plugin-core/src/prerender-route-options.ts new file mode 100644 index 00000000000..f38ffcf3433 --- /dev/null +++ b/packages/start-plugin-core/src/prerender-route-options.ts @@ -0,0 +1,80 @@ +import type { AnyRoute } from '@tanstack/router-core' +import type { + PrerenderParamsEntry, + RouteSitemapOptions, +} from '@tanstack/start-client-core' + +export interface PrerenderRouteMetadata { + path: string + routePath: string +} + +export interface PrerenderRouteOptions { + prerenderParams?: (ctx: { + routePath: string + signal: AbortSignal + }) => + | ReadonlyArray>> + | Promise>>> + sitemap?: RouteSitemapOptions +} + +export function collectPrerenderRouteOptions(routeTree: AnyRoute | undefined): { + routeOptions: Map + dynamicRoutes: Array + sitemapRoutes: Array +} { + const routeOptions = new Map() + const dynamicRoutes: Array = [] + const sitemapRoutes: Array = [] + + if (!routeTree) { + return { routeOptions, dynamicRoutes, sitemapRoutes } + } + + visit(routeTree) + + return { routeOptions, dynamicRoutes, sitemapRoutes } + + function visit(route: AnyRoute) { + const options = route.options as PrerenderRouteOptions & { + id?: string + path?: string + } + const routePath = route.id ?? options.id ?? options.path + const path = route.fullPath ?? options.path ?? routePath + + if (routePath && path) { + const metadata = { + path, + routePath, + } + + if (options.prerenderParams) { + dynamicRoutes.push(metadata) + } + + if (options.sitemap) { + sitemapRoutes.push(metadata) + } + + if (options.prerenderParams || options.sitemap) { + routeOptions.set(routePath, { + prerenderParams: options.prerenderParams, + sitemap: options.sitemap, + }) + } + } + + const children = route.children + if (!children) { + return + } + + for (const child of Array.isArray(children) + ? children + : Object.values(children)) { + visit(child as AnyRoute) + } + } +} diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts index 2fbd9e74b9c..b04eefd1d01 100644 --- a/packages/start-plugin-core/src/prerender.ts +++ b/packages/start-plugin-core/src/prerender.ts @@ -4,6 +4,7 @@ import path from 'pathe' import { joinURL, withBase, withTrailingSlash, withoutBase } from 'ufo' import { createLogger } from './utils' import { Queue } from './queue' +import { runPrerenderParams } from './prerender-params-runner' import type { Page, TanStackStartOutputConfig } from './schema' const DEFAULT_RETRY_DELAY = 500 @@ -40,6 +41,16 @@ export async function prerender({ pages = Array.from(pagesMap.values()) } + const routeTree = await globalThis.TSS_PRERENDER_ROUTE_TREE?.() + + pages = await runPrerenderParams({ + routeTree, + pages, + logger, + filter: startConfig.prerender.filter, + prerenderParamsTimeout: startConfig.prerender.prerenderParamsTimeout, + }) + startConfig.pages = pages } @@ -282,7 +293,7 @@ export function validateAndNormalizePrerenderPages( throw new Error(`prerender page path must be relative: ${page.path}`) } - const decodedPathname = decodeURIComponent(url.pathname) + const decodedPathname = decodeURI(url.pathname) return { ...page, diff --git a/packages/start-plugin-core/src/rsbuild/post-build.ts b/packages/start-plugin-core/src/rsbuild/post-build.ts index 2590ec9931a..7464c286515 100644 --- a/packages/start-plugin-core/src/rsbuild/post-build.ts +++ b/packages/start-plugin-core/src/rsbuild/post-build.ts @@ -19,13 +19,16 @@ export async function postBuildWithRsbuild({ getClientOutputDirectory() { return clientOutputDirectory }, - prerender(startConfig) { + async prerender(startConfig) { + const handler = createRsbuildPrerenderHandler({ + clientOutputDirectory, + serverOutputDirectory, + }) + await handler.loadRequestHandler() + return prerender({ startConfig, - handler: createRsbuildPrerenderHandler({ - clientOutputDirectory, - serverOutputDirectory, - }), + handler, }) }, }, @@ -38,7 +41,11 @@ function createRsbuildPrerenderHandler({ }: { clientOutputDirectory: string serverOutputDirectory: string -}): PrerenderHandler { +}): PrerenderHandler & { + loadRequestHandler: () => Promise< + (request: Request, opts?: unknown) => Promise | Response + > +} { process.env.TSS_PRERENDERING = 'true' process.env.TSS_CLIENT_OUTPUT_DIR = clientOutputDirectory @@ -49,11 +56,12 @@ function createRsbuildPrerenderHandler({ | undefined return { + loadRequestHandler, getClientOutputDirectory() { return clientOutputDirectory }, async request(path, options) { - const requestHandler = await getRequestHandler() + const requestHandler = await loadRequestHandler() const url = new URL(path, 'http://localhost') return requestHandler( @@ -65,16 +73,18 @@ function createRsbuildPrerenderHandler({ }, } - function getRequestHandler() { + function loadRequestHandler() { if (!requestHandlerPromise) { - requestHandlerPromise = loadRequestHandler(serverOutputDirectory) + requestHandlerPromise = loadRequestHandlerFromBundle( + serverOutputDirectory, + ) } return requestHandlerPromise } } -async function loadRequestHandler(serverOutputDirectory: string) { +async function loadRequestHandlerFromBundle(serverOutputDirectory: string) { const { pathToFileURL } = await import('node:url') const serverEntryUrl = pathToFileURL( join(serverOutputDirectory, 'index.js'), diff --git a/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts b/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts index 1f512089feb..36d3228f529 100644 --- a/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts @@ -7,6 +7,7 @@ import { import { routesManifestPlugin } from '../start-router-plugin/generator-plugins/routes-manifest-plugin' import { prerenderRoutesPlugin } from '../start-router-plugin/generator-plugins/prerender-routes-plugin' import { buildRouteTreeFileFooterFromConfig } from '../start-router-plugin/route-tree-footer' +import { CLIENT_ROUTE_OPTION_DELETE_NODES } from '../start-router-plugin/constants' import { RSBUILD_ENVIRONMENT_NAMES } from './planning' import type { RsbuildPluginAPI } from '@rsbuild/core' import type { GetConfigFn, TanStackStartCoreOptions } from '../types' @@ -52,7 +53,7 @@ export function registerRouterPlugins( }, plugins: [ routesManifestPlugin(), - ...(opts.startPluginOpts?.prerender?.enabled === true + ...(opts.startPluginOpts?.prerender?.enabled !== false ? [prerenderRoutesPlugin()] : []), ], @@ -73,7 +74,7 @@ export function registerRouterPlugins( target: opts.corePluginOpts.framework, codeSplittingOptions: { ...routerConfig.codeSplittingOptions, - deleteNodes: isClient ? ['ssr', 'server', 'headers'] : undefined, + deleteNodes: isClient ? CLIENT_ROUTE_OPTION_DELETE_NODES : undefined, addHmr: isClient, }, }, diff --git a/packages/start-plugin-core/src/schema.ts b/packages/start-plugin-core/src/schema.ts index e0287858e63..6a08120d37c 100644 --- a/packages/start-plugin-core/src/schema.ts +++ b/packages/start-plugin-core/src/schema.ts @@ -253,6 +253,7 @@ export const tanstackStartOptionsObjectSchema = z.object({ failOnError: z.boolean().optional(), autoStaticPathsDiscovery: z.boolean().optional(), maxRedirects: z.number().min(0).optional(), + prerenderParamsTimeout: z.number().min(0).optional(), }) .and(pagePrerenderOptionsSchema.optional()) .optional(), diff --git a/packages/start-plugin-core/src/start-router-plugin/constants.ts b/packages/start-plugin-core/src/start-router-plugin/constants.ts index 645c550aac4..273ae471884 100644 --- a/packages/start-plugin-core/src/start-router-plugin/constants.ts +++ b/packages/start-plugin-core/src/start-router-plugin/constants.ts @@ -1 +1,8 @@ export const SERVER_PROP = 'server' +export const CLIENT_ROUTE_OPTION_DELETE_NODES = [ + 'ssr', + 'server', + 'headers', + 'prerenderParams', + 'sitemap', +] diff --git a/packages/start-plugin-core/src/start-router-plugin/generator-plugins/prerender-routes-plugin.ts b/packages/start-plugin-core/src/start-router-plugin/generator-plugins/prerender-routes-plugin.ts index b614ae4afae..c1e5d398743 100644 --- a/packages/start-plugin-core/src/start-router-plugin/generator-plugins/prerender-routes-plugin.ts +++ b/packages/start-plugin-core/src/start-router-plugin/generator-plugins/prerender-routes-plugin.ts @@ -10,6 +10,17 @@ export function prerenderRoutesPlugin(): GeneratorPlugin { name: 'prerender-routes-plugin', onRouteTreeChanged: ({ routeNodes }) => { globalThis.TSS_PRERENDABLE_PATHS = getPrerenderablePaths(routeNodes) + const seenDynamicRoutes = new Set() + + globalThis.TSS_PRERENDER_DYNAMIC_ROUTES = routeNodes.flatMap((route) => { + if (!route.routePath) return [] + if (!route.createFileRouteProps?.has('prerenderParams')) return [] + if (seenDynamicRoutes.has(route.routePath)) return [] + + seenDynamicRoutes.add(route.routePath) + + return [{ path: inferFullPath(route), routePath: route.routePath }] + }) }, } } diff --git a/packages/start-plugin-core/src/vite/prerender.ts b/packages/start-plugin-core/src/vite/prerender.ts index 5eeb76e8d50..da426a18635 100644 --- a/packages/start-plugin-core/src/vite/prerender.ts +++ b/packages/start-plugin-core/src/vite/prerender.ts @@ -1,7 +1,11 @@ +import { pathToFileURL } from 'node:url' +import { basename, extname, join } from 'pathe' import { VITE_ENVIRONMENT_NAMES } from '../constants' import { prerender } from '../prerender' -import type { PrerenderHandler } from '../prerender' +import { getBundlerOptions } from '../utils' +import { getServerOutputDirectory } from './output-directory' import type { TanStackStartOutputConfig } from '../schema' +import type { PrerenderHandler } from '../prerender' import type { PreviewServer, ResolvedConfig, ViteBuilder } from 'vite' export async function prerenderWithVite({ @@ -31,6 +35,20 @@ export async function prerenderWithVite({ process.env.TSS_PRERENDERING = 'true' process.env.TSS_CLIENT_OUTPUT_DIR = outputDir + const serverInput = + getBundlerOptions(serverEnv.config.build)?.input ?? 'server' + + if (typeof serverInput !== 'string') { + throw new Error('Invalid server input. Expected a string.') + } + + // Import the built server entry before prerendering so route options from the + // initialized router are available for dynamic route discovery. + const outputFilename = `${basename(serverInput, extname(serverInput))}.js` + const serverOutputDir = getServerOutputDirectory(serverEnv.config) + const serverEntryPath = join(serverOutputDir, outputFilename) + await import(pathToFileURL(serverEntryPath).toString()) + const previewServer = await startPreviewServer(serverEnv.config) const baseUrl = getResolvedUrl(previewServer) diff --git a/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts index e4f06893a7c..c9c86b3886d 100644 --- a/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts @@ -10,7 +10,10 @@ import { routesManifestPlugin } from '../../start-router-plugin/generator-plugin import { prerenderRoutesPlugin } from '../../start-router-plugin/generator-plugins/prerender-routes-plugin' import { buildRouteTreeFileFooterFromConfig } from '../../start-router-plugin/route-tree-footer' import { pruneServerOnlySubtrees } from '../../start-router-plugin/pruneServerOnlySubtrees' -import { SERVER_PROP } from '../../start-router-plugin/constants' +import { + CLIENT_ROUTE_OPTION_DELETE_NODES, + SERVER_PROP, +} from '../../start-router-plugin/constants' import type { GetConfigFn } from '../../types' import type { TanStackStartVitePluginCoreOptions } from '../types' import type { @@ -147,7 +150,8 @@ export function tanStackStartRouter( tanstackRouterGenerator(() => { const routerConfig = getConfig().startConfig.router const plugins = [clientTreeGeneratorPlugin, routesManifestPlugin()] - if (startPluginOpts.prerender?.enabled === true) { + // Dynamic route params can enable prerendering after route generation. + if (startPluginOpts.prerender?.enabled !== false) { plugins.push(prerenderRoutesPlugin()) } return { @@ -163,7 +167,7 @@ export function tanStackStartRouter( ...routerConfig, codeSplittingOptions: { ...routerConfig.codeSplittingOptions, - deleteNodes: ['ssr', 'server', 'headers'], + deleteNodes: CLIENT_ROUTE_OPTION_DELETE_NODES, addHmr: true, }, plugin: { diff --git a/packages/start-plugin-core/tests/build-sitemap.test.ts b/packages/start-plugin-core/tests/build-sitemap.test.ts new file mode 100644 index 00000000000..2f855b3bb24 --- /dev/null +++ b/packages/start-plugin-core/tests/build-sitemap.test.ts @@ -0,0 +1,158 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { buildSitemap } from '../src/build-sitemap' + +const tempDirs: Array = [] + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('buildSitemap', () => { + it('includes generated page search params unless excluded', () => { + const publicDir = mkdtempSync(join(tmpdir(), 'tanstack-start-sitemap-')) + tempDirs.push(publicDir) + + buildSitemap({ + publicDir, + startConfig: { + sitemap: { + enabled: true, + host: 'https://example.com', + outputPath: 'sitemap.xml', + }, + pages: [ + { path: '/products/router?page=2&tag=start' }, + { + path: '/products/draft?preview=true', + sitemap: { exclude: true }, + }, + ], + } as any, + }) + + const sitemap = readFileSync(join(publicDir, 'sitemap.xml'), 'utf-8') + + expect(sitemap).toContain( + 'https://example.com/products/router?page=2&tag=start', + ) + expect(sitemap).not.toContain('preview=true') + }) + + it('preserves sitemap metadata for query URLs without duplicating host slashes', () => { + const publicDir = mkdtempSync(join(tmpdir(), 'tanstack-start-sitemap-')) + tempDirs.push(publicDir) + + buildSitemap({ + publicDir, + startConfig: { + sitemap: { + enabled: true, + host: 'https://example.com/', + outputPath: 'sitemap.xml', + }, + pages: [ + { + path: '/blog/router?tag=router+start', + sitemap: { + lastmod: new Date('2026-05-05T12:30:00.000Z'), + priority: 0.8, + changefreq: 'weekly', + alternateRefs: [ + { + href: 'https://example.com/ko/blog/router', + hreflang: 'ko', + }, + ], + }, + }, + ], + } as any, + }) + + const sitemap = readFileSync(join(publicDir, 'sitemap.xml'), 'utf-8') + const pagesJson = readFileSync(join(publicDir, 'pages.json'), 'utf-8') + + expect(sitemap).toContain( + 'https://example.com/blog/router?tag=router+start', + ) + expect(sitemap).toContain('2026-05-05') + expect(sitemap).toContain('0.8') + expect(sitemap).toContain('weekly') + expect(sitemap).toContain('href="https://example.com/ko/blog/router"') + expect(sitemap).toContain('hreflang="ko"') + expect(sitemap).not.toContain('https://example.com//blog/router') + expect(pagesJson).toContain('/blog/router?tag=router+start') + }) + + it('writes advanced sitemap metadata', () => { + const publicDir = mkdtempSync(join(tmpdir(), 'tanstack-start-sitemap-')) + tempDirs.push(publicDir) + + buildSitemap({ + publicDir, + startConfig: { + sitemap: { + enabled: true, + host: 'https://example.com', + outputPath: 'sitemap.xml', + }, + pages: [ + { + path: '/news/router-launch', + sitemap: { + alternateRefs: [ + { + href: 'https://example.com/ko/news/router-launch', + hreflang: 'ko', + }, + ], + images: [ + { + loc: 'https://example.com/router.png', + title: 'Router', + caption: 'TanStack Router', + }, + ], + news: { + publication: { + name: 'TanStack', + language: 'en', + }, + publicationDate: '2026-05-05', + title: 'Router Launch', + }, + }, + }, + ], + } as any, + }) + + const sitemap = readFileSync(join(publicDir, 'sitemap.xml'), 'utf-8') + + expect(sitemap).toContain( + 'xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"', + ) + expect(sitemap).toContain( + 'xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"', + ) + expect(sitemap).toContain( + 'href="https://example.com/ko/news/router-launch"', + ) + expect(sitemap).toContain( + 'https://example.com/router.png', + ) + expect(sitemap).toContain('Router') + expect(sitemap).toContain('TanStack Router') + expect(sitemap).toContain('TanStack') + expect(sitemap).toContain('en') + expect(sitemap).toContain( + '2026-05-05', + ) + expect(sitemap).toContain('Router Launch') + }) +}) diff --git a/packages/start-plugin-core/tests/prerender-params-runner.test.ts b/packages/start-plugin-core/tests/prerender-params-runner.test.ts new file mode 100644 index 00000000000..a7e96898944 --- /dev/null +++ b/packages/start-plugin-core/tests/prerender-params-runner.test.ts @@ -0,0 +1,522 @@ +import { describe, expect, it, vi } from 'vitest' +import { runPrerenderParams } from '../src/prerender-params-runner' + +const logger = { + warn: vi.fn(), +} + +function createRouteTree(optionsById: Record) { + return { + options: {}, + children: Object.entries(optionsById).map(([id, options]) => ({ + id, + fullPath: id, + options: { + id, + ...options, + }, + })), + } as any +} + +describe('runPrerenderParams', () => { + it('expands dynamic route params into pages', async () => { + const routeTree = createRouteTree({ + '/posts/$slug': { + sitemap: { priority: 0.7 }, + prerenderParams: () => [ + { + params: { slug: 'hello-world' }, + sitemap: { lastmod: '2026-05-05' }, + prerender: { retryCount: 1 }, + }, + ], + }, + }) + + const pages = await runPrerenderParams({ + routeTree, + pages: [], + logger, + }) + + expect(pages).toEqual([ + { + path: '/posts/hello-world', + sitemap: { priority: 0.7, lastmod: '2026-05-05' }, + prerender: { retryCount: 1 }, + }, + ]) + }) + + it('supports multiple params, optional params, and splats', async () => { + const routeTree = createRouteTree({ + '/posts/$category/$slug': { + prerenderParams: () => [ + { params: { category: 'guides', slug: 'routing' } }, + ], + }, + '/optional/{-$category}/{-$slug}': { + prerenderParams: () => [ + { params: {} }, + { params: { category: 'guides' } }, + { params: { category: 'guides', slug: 'routing' } }, + ], + }, + '/files/$': { + prerenderParams: () => [ + { params: { _splat: 'docs/routing guide.md' } }, + ], + }, + }) + + const pages = await runPrerenderParams({ + routeTree, + pages: [], + logger, + }) + + expect(pages.map((page) => page.path)).toEqual([ + '/posts/guides/routing', + '/optional', + '/optional/guides', + '/optional/guides/routing', + '/files/docs/routing%20guide.md', + ]) + }) + + it('encodes reserved characters in generated path params', async () => { + const routeTree = createRouteTree({ + '/posts/$slug': { + prerenderParams: () => [ + { params: { slug: 'space + percent% [page] ?hash#' } }, + ], + }, + '/files/$': { + prerenderParams: () => [ + { params: { _splat: 'docs/routing guide/[page]?draft#intro' } }, + ], + }, + }) + + const pages = await runPrerenderParams({ + routeTree, + pages: [], + logger, + }) + + expect(pages.map((page) => page.path)).toEqual([ + '/posts/space%20%2B%20percent%25%20%5Bpage%5D%20%3Fhash%23', + '/files/docs/routing%20guide/%5Bpage%5D%3Fdraft%23intro', + ]) + }) + + it('preserves search params on generated pages', async () => { + const routeTree = createRouteTree({ + '/products/$slug': { + sitemap: { changefreq: 'daily' }, + prerenderParams: () => [ + { + params: { slug: 'router' }, + search: { page: 2, tag: 'start' }, + sitemap: { priority: 0.4 }, + }, + ], + }, + }) + + const pages = await runPrerenderParams({ + routeTree, + pages: [], + logger, + }) + + expect(pages).toEqual([ + { + path: '/products/router?page=2&tag=start', + sitemap: { changefreq: 'daily', priority: 0.4 }, + prerender: undefined, + }, + ]) + }) + + it('preserves advanced sitemap and prerender options on generated pages', async () => { + const routeTree = createRouteTree({ + '/news/$slug': { + sitemap: { changefreq: 'weekly' }, + prerenderParams: () => [ + { + params: { slug: 'router-launch' }, + sitemap: { + priority: 0.9, + alternateRefs: [ + { + href: 'https://example.com/ko/news/router-launch', + hreflang: 'ko', + }, + ], + images: [ + { + loc: 'https://example.com/router.png', + title: 'Router', + caption: 'TanStack Router', + }, + ], + news: { + publication: { + name: 'TanStack', + language: 'en', + }, + publicationDate: '2026-05-05', + title: 'Router Launch', + }, + }, + prerender: { + outputPath: '/custom-news/router-launch', + autoSubfolderIndex: false, + retryCount: 2, + }, + }, + ], + }, + }) + + const pages = await runPrerenderParams({ + routeTree, + pages: [], + logger, + }) + + expect(pages).toEqual([ + { + path: '/news/router-launch', + sitemap: { + changefreq: 'weekly', + priority: 0.9, + alternateRefs: [ + { + href: 'https://example.com/ko/news/router-launch', + hreflang: 'ko', + }, + ], + images: [ + { + loc: 'https://example.com/router.png', + title: 'Router', + caption: 'TanStack Router', + }, + ], + news: { + publication: { + name: 'TanStack', + language: 'en', + }, + publicationDate: '2026-05-05', + title: 'Router Launch', + }, + }, + prerender: { + outputPath: '/custom-news/router-launch', + autoSubfolderIndex: false, + retryCount: 2, + }, + }, + ]) + }) + + it('serializes generated page search params with router defaults', async () => { + const routeTree = createRouteTree({ + '/products/$slug': { + prerenderParams: () => [ + { + params: { slug: 'router' }, + search: { + q: 'router start', + page: 2, + filters: { category: 'docs' }, + empty: undefined, + }, + }, + ], + }, + }) + + const pages = await runPrerenderParams({ + routeTree, + pages: [], + logger, + }) + + expect(pages.map((page) => page.path)).toEqual([ + '/products/router?q=router+start&page=2&filters=%7B%22category%22%3A%22docs%22%7D', + ]) + }) + + it('passes routePath and an abort signal to async prerenderParams', async () => { + const prerenderParams = vi.fn(async ({ routePath, signal }) => { + expect(routePath).toBe('/products/$slug') + expect(signal).toBeInstanceOf(AbortSignal) + + return [{ params: { slug: 'router' } }] + }) + const routeTree = createRouteTree({ + '/products/$slug': { + prerenderParams, + }, + }) + + await expect( + runPrerenderParams({ + routeTree, + pages: [], + logger, + }), + ).resolves.toEqual([ + { + path: '/products/router', + sitemap: undefined, + prerender: undefined, + }, + ]) + expect(prerenderParams).toHaveBeenCalledTimes(1) + }) + + it('aborts prerenderParams when the timeout elapses', async () => { + vi.useFakeTimers() + try { + const prerenderParams = vi.fn(({ signal }) => { + return new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(signal.reason)) + }) + }) + const routeTree = createRouteTree({ + '/products/$slug': { + prerenderParams, + }, + }) + + const result = runPrerenderParams({ + routeTree, + pages: [], + logger, + prerenderParamsTimeout: 100, + }) + const expectation = expect(result).rejects.toThrow( + 'prerenderParams for route /products/$slug timed out', + ) + + await vi.advanceTimersByTimeAsync(100) + await expectation + } finally { + vi.useRealTimers() + } + }) + + it('aborts prerenderParams when the process is interrupted', async () => { + const prerenderParams = vi.fn(({ signal }) => { + return new Promise((_, reject) => { + signal.addEventListener('abort', () => { + reject(new Error('signal aborted')) + }) + }) + }) + const routeTree = createRouteTree({ + '/products/$slug': { + prerenderParams, + }, + }) + + const result = runPrerenderParams({ + routeTree, + pages: [], + logger, + }) + const expectation = expect(result).rejects.toThrow( + 'This operation was aborted', + ) + + process.emit('SIGTERM') + await expectation + }) + + it('applies route sitemap options to static pages', async () => { + const routeTree = createRouteTree({ + '/about': { + sitemap: { changefreq: 'weekly' }, + }, + }) + + const pages = await runPrerenderParams({ + routeTree, + pages: [{ path: '/about', sitemap: { priority: 0.5 } }], + logger, + }) + + expect(pages).toEqual([ + { + path: '/about', + sitemap: { priority: 0.5, changefreq: 'weekly' }, + }, + ]) + }) + + it('lets existing pages take precedence over generated duplicates', async () => { + const routeTree = createRouteTree({ + '/posts/$slug': { + sitemap: { priority: 0.3, changefreq: 'daily' }, + prerenderParams: () => [ + { + params: { slug: 'hello-world' }, + sitemap: { priority: 0.7 }, + prerender: { retryCount: 1 }, + }, + ], + }, + }) + + const pages = await runPrerenderParams({ + routeTree, + pages: [ + { + path: '/posts/hello-world', + sitemap: { changefreq: 'weekly' }, + prerender: { retryCount: 3 }, + }, + ], + logger, + }) + + expect(pages).toEqual([ + { + path: '/posts/hello-world', + sitemap: { priority: 0.7, changefreq: 'weekly' }, + prerender: { retryCount: 3 }, + }, + ]) + }) + + it('lets the first generated page take precedence over generated duplicates', async () => { + const routeTree = createRouteTree({ + '/posts/$slug': { + sitemap: { changefreq: 'daily' }, + prerenderParams: () => [ + { + params: { slug: 'hello-world' }, + sitemap: { priority: 0.7 }, + prerender: { retryCount: 1 }, + }, + { + params: { slug: 'hello-world' }, + sitemap: { priority: 0.3, lastmod: '2026-05-05' }, + prerender: { retryCount: 3 }, + }, + ], + }, + }) + + const pages = await runPrerenderParams({ + routeTree, + pages: [], + logger, + }) + + expect(pages).toEqual([ + { + path: '/posts/hello-world', + sitemap: { + changefreq: 'daily', + priority: 0.7, + lastmod: '2026-05-05', + }, + prerender: { retryCount: 1 }, + }, + ]) + }) + + it('filters generated dynamic pages before sitemap generation', async () => { + const routeTree = createRouteTree({ + '/posts/$slug': { + prerenderParams: () => [ + { params: { slug: 'keep' } }, + { params: { slug: 'drop' } }, + ], + }, + }) + + const pages = await runPrerenderParams({ + routeTree, + pages: [], + logger, + filter: (page) => page.path !== '/posts/drop', + }) + + expect(pages.map((page) => page.path)).toEqual(['/posts/keep']) + }) + + it('warns and skips prerenderParams on static routes', async () => { + logger.warn.mockClear() + const routeTree = createRouteTree({ + '/about': { + prerenderParams: () => [{ params: {} }], + }, + }) + + const pages = await runPrerenderParams({ + routeTree, + pages: [], + logger, + }) + + expect(pages).toEqual([]) + expect(logger.warn).toHaveBeenCalledWith( + 'Skipping prerenderParams for static route /about; static routes are already discovered automatically.', + ) + }) + + it('throws when a prerenderParams entry is missing required params', async () => { + const routeTree = createRouteTree({ + '/posts/$slug': { + prerenderParams: () => [{ params: {} }], + }, + }) + + await expect( + runPrerenderParams({ + routeTree, + pages: [], + logger, + }), + ).rejects.toThrow('Missing prerenderParams values for route /posts/$slug') + }) + + it('throws when a prerenderParams entry has nullish required params', async () => { + const routeTree = createRouteTree({ + '/posts/$slug': { + prerenderParams: () => [ + { params: { slug: undefined } }, + { params: { slug: null } }, + ], + }, + }) + + await expect( + runPrerenderParams({ + routeTree, + pages: [], + logger, + }), + ).rejects.toThrow('Missing prerenderParams values for route /posts/$slug') + }) + + it('returns existing pages when no prerender route tree is available', async () => { + const pages = [{ path: '/about', sitemap: { priority: 0.5 } }] + + await expect( + runPrerenderParams({ + routeTree: undefined, + pages, + logger, + }), + ).resolves.toEqual(pages) + }) +}) diff --git a/packages/start-plugin-core/tests/prerender-routes-plugin.test.ts b/packages/start-plugin-core/tests/prerender-routes-plugin.test.ts new file mode 100644 index 00000000000..c48948865df --- /dev/null +++ b/packages/start-plugin-core/tests/prerender-routes-plugin.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { prerenderRoutesPlugin } from '../src/start-router-plugin/generator-plugins/prerender-routes-plugin' + +describe('prerenderRoutesPlugin', () => { + afterEach(() => { + globalThis.TSS_PRERENDABLE_PATHS = undefined + globalThis.TSS_PRERENDER_DYNAMIC_ROUTES = undefined + }) + + it('stores static and dynamic prerender routes on globalThis', () => { + const plugin = prerenderRoutesPlugin() + + plugin.onRouteTreeChanged?.({ + routeTree: [], + rootRouteNode: { fullPath: '/src/routes/__root.tsx' } as any, + routeNodes: [ + { + routePath: '/about', + path: 'about', + fullPath: '/src/routes/about.tsx', + createFileRouteProps: new Set(['component', 'sitemap']), + }, + { + routePath: '/posts/$slug', + path: '$slug', + fullPath: '/src/routes/posts.$slug.tsx', + createFileRouteProps: new Set(['component', 'prerenderParams']), + }, + ], + } as any) + + expect(globalThis.TSS_PRERENDABLE_PATHS).toContainEqual({ path: '/about' }) + expect(globalThis.TSS_PRERENDER_DYNAMIC_ROUTES).toContainEqual({ + path: '/posts/$slug', + routePath: '/posts/$slug', + }) + }) + + it('does not store API, layout, or dynamic routes as static paths', () => { + const plugin = prerenderRoutesPlugin() + + plugin.onRouteTreeChanged?.({ + routeTree: [], + rootRouteNode: { fullPath: '/src/routes/__root.tsx' } as any, + routeNodes: [ + { + routePath: '/api/users', + path: 'api/users', + fullPath: '/src/routes/api.users.ts', + createFileRouteProps: new Set(), + }, + { + routePath: '/_layout', + path: '_layout', + fullPath: '/src/routes/_layout.tsx', + isNonPath: true, + createFileRouteProps: new Set(['component']), + }, + { + routePath: '/posts/$slug', + path: '$slug', + fullPath: '/src/routes/posts.$slug.tsx', + createFileRouteProps: new Set(['component']), + }, + ], + } as any) + + expect(globalThis.TSS_PRERENDABLE_PATHS).toEqual([{ path: '/' }]) + }) + + it('stores only prerenderParams routes as dynamic prerender hints', () => { + const plugin = prerenderRoutesPlugin() + + plugin.onRouteTreeChanged?.({ + routeTree: [], + rootRouteNode: { fullPath: '/src/routes/__root.tsx' } as any, + routeNodes: [ + { + routePath: '/posts/$slug', + path: '$slug', + fullPath: '/src/routes/posts.$slug.tsx', + createFileRouteProps: new Set(['component', 'prerenderParams']), + }, + { + routePath: '/posts/$slug', + path: '$slug', + fullPath: '/src/routes/posts.$slug.tsx', + createFileRouteProps: new Set(['component', 'prerenderParams']), + }, + { + routePath: '/products/$slug', + path: '$slug', + fullPath: '/src/routes/products.$slug.tsx', + createFileRouteProps: new Set(['component', 'sitemap']), + }, + { + path: '$slug', + fullPath: '/src/routes/missing-route-path.$slug.tsx', + createFileRouteProps: new Set(['component', 'prerenderParams']), + }, + ], + } as any) + + expect(globalThis.TSS_PRERENDER_DYNAMIC_ROUTES).toEqual([ + { + path: '/posts/$slug', + routePath: '/posts/$slug', + }, + ]) + }) +}) diff --git a/packages/start-plugin-core/tests/prerender-ssrf.test.ts b/packages/start-plugin-core/tests/prerender-ssrf.test.ts index e245a132493..e9f9d349b79 100644 --- a/packages/start-plugin-core/tests/prerender-ssrf.test.ts +++ b/packages/start-plugin-core/tests/prerender-ssrf.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { prerender } from '../src/prerender' +import { prerender, validateAndNormalizePrerenderPages } from '../src/prerender' vi.mock('../src/utils', async () => { const actual = await vi.importActual('../src/utils') @@ -88,4 +88,21 @@ describe('prerender pages validation', () => { await expect(prerender({ startConfig, handler })).resolves.not.toThrow() expect(fetchMock).not.toHaveBeenCalled() }) + + it('preserves encoded path delimiters while decoding unicode path params', () => { + expect( + validateAndNormalizePrerenderPages( + [ + { + path: '/posts/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD%2Fdocs%3Fdraft%23intro?tag=router+start', + }, + ], + new URL('http://localhost'), + ), + ).toEqual([ + { + path: '/posts/대한민국%2Fdocs%3Fdraft%23intro?tag=router+start', + }, + ]) + }) }) diff --git a/packages/start-plugin-core/tests/start-router-plugin-constants.test.ts b/packages/start-plugin-core/tests/start-router-plugin-constants.test.ts new file mode 100644 index 00000000000..88678e8b528 --- /dev/null +++ b/packages/start-plugin-core/tests/start-router-plugin-constants.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { CLIENT_ROUTE_OPTION_DELETE_NODES } from '../src/start-router-plugin/constants' + +describe('client route option stripping', () => { + it('strips server-only and prerender route options from client bundles', () => { + expect(CLIENT_ROUTE_OPTION_DELETE_NODES).toEqual([ + 'ssr', + 'server', + 'headers', + 'prerenderParams', + 'sitemap', + ]) + }) +}) diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index a31a81fde82..24814d801fb 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -370,6 +370,16 @@ function getEntries() { return entriesPromise } +if (process.env.TSS_PRERENDERING === 'true') { + // The prerenderer imports the server entry before crawling so it can read + // server-only route options like prerenderParams from the initialized router. + globalThis.TSS_PRERENDER_ROUTE_TREE = async () => { + const entries = await getEntries() + const router = await entries.routerEntry.getRouter() + return router.routeTree + } +} + /** * Returns the raw manifest data (without client entry script tag baked in). * In dev mode, always returns fresh data. In prod, cached. diff --git a/packages/start-server-core/src/global.d.ts b/packages/start-server-core/src/global.d.ts index dd9048293ef..e357c26767e 100644 --- a/packages/start-server-core/src/global.d.ts +++ b/packages/start-server-core/src/global.d.ts @@ -1,4 +1,12 @@ +import type { AnyRoute } from '@tanstack/router-core' + +/* eslint-disable no-var */ declare global { + /** Set by the built server entry while prerendering dynamic route params. */ + var TSS_PRERENDER_ROUTE_TREE: + | (() => Promise) + | undefined + namespace NodeJS { interface ProcessEnv { TSS_ROUTER_BASEPATH: string From cb7a05dd953af4a4e18bbdb9cee13159ebac06f8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 15:25:38 +0000 Subject: [PATCH 02/11] ci: apply automated fixes --- e2e/react-start/basic/tests/prerendering.spec.ts | 5 +---- e2e/solid-start/basic/tests/prerendering.spec.ts | 5 +---- e2e/vue-start/basic/tests/prerendering.spec.ts | 5 +---- .../start-plugin-core/src/rsbuild/start-router-plugin.ts | 4 +++- 4 files changed, 6 insertions(+), 13 deletions(-) diff --git a/e2e/react-start/basic/tests/prerendering.spec.ts b/e2e/react-start/basic/tests/prerendering.spec.ts index 8d7531bb5a6..2900c44d195 100644 --- a/e2e/react-start/basic/tests/prerendering.spec.ts +++ b/e2e/react-start/basic/tests/prerendering.spec.ts @@ -60,10 +60,7 @@ test.describe('Prerender Static Path Discovery', () => { }) test('should prerender dynamic routes through nested pathless outlets', () => { - const htmlPath = join( - distDir, - 'prerender-nested/under-layout/index.html', - ) + const htmlPath = join(distDir, 'prerender-nested/under-layout/index.html') expect(existsSync(htmlPath)).toBe(true) diff --git a/e2e/solid-start/basic/tests/prerendering.spec.ts b/e2e/solid-start/basic/tests/prerendering.spec.ts index 73075a49f1e..8c030122601 100644 --- a/e2e/solid-start/basic/tests/prerendering.spec.ts +++ b/e2e/solid-start/basic/tests/prerendering.spec.ts @@ -60,10 +60,7 @@ test.describe('Prerender Static Path Discovery', () => { }) test('should prerender dynamic routes through nested pathless outlets', () => { - const htmlPath = join( - distDir, - 'prerender-nested/under-layout/index.html', - ) + const htmlPath = join(distDir, 'prerender-nested/under-layout/index.html') expect(existsSync(htmlPath)).toBe(true) diff --git a/e2e/vue-start/basic/tests/prerendering.spec.ts b/e2e/vue-start/basic/tests/prerendering.spec.ts index 73075a49f1e..8c030122601 100644 --- a/e2e/vue-start/basic/tests/prerendering.spec.ts +++ b/e2e/vue-start/basic/tests/prerendering.spec.ts @@ -60,10 +60,7 @@ test.describe('Prerender Static Path Discovery', () => { }) test('should prerender dynamic routes through nested pathless outlets', () => { - const htmlPath = join( - distDir, - 'prerender-nested/under-layout/index.html', - ) + const htmlPath = join(distDir, 'prerender-nested/under-layout/index.html') expect(existsSync(htmlPath)).toBe(true) diff --git a/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts b/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts index 36d3228f529..377d4ab56e5 100644 --- a/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts @@ -74,7 +74,9 @@ export function registerRouterPlugins( target: opts.corePluginOpts.framework, codeSplittingOptions: { ...routerConfig.codeSplittingOptions, - deleteNodes: isClient ? CLIENT_ROUTE_OPTION_DELETE_NODES : undefined, + deleteNodes: isClient + ? CLIENT_ROUTE_OPTION_DELETE_NODES + : undefined, addHmr: isClient, }, }, From 7990d411cf9614bf25f14a4f03a5667a174fbf13 Mon Sep 17 00:00:00 2001 From: jon Date: Tue, 5 May 2026 16:56:57 +0100 Subject: [PATCH 03/11] fix: address prerender review feedback --- .../basic/tests/prerendering.spec.ts | 4 +-- .../basic/tests/prerendering.spec.ts | 6 ++-- .../basic/tests/prerendering.spec.ts | 6 ++-- packages/start-plugin-core/src/post-build.ts | 3 +- .../src/prerender-params-runner.ts | 8 ++++- .../tests/post-server-build.test.ts | 29 +++++++++++++++++++ 6 files changed, 43 insertions(+), 13 deletions(-) diff --git a/e2e/react-start/basic/tests/prerendering.spec.ts b/e2e/react-start/basic/tests/prerendering.spec.ts index 2900c44d195..90ef4327a5f 100644 --- a/e2e/react-start/basic/tests/prerendering.spec.ts +++ b/e2e/react-start/basic/tests/prerendering.spec.ts @@ -112,8 +112,8 @@ test.describe('Prerender Static Path Discovery', () => { const html = readFileSync(htmlPath, 'utf-8') expect(html).toContain('Prerendered slug:') expect(html).toContain('with-query') - expect(html).toContain('Search page: 2') - expect(html).toContain('Search tag: router start') + expect(html).toMatch(/Search page:(?:\s|)*2/) + expect(html).toMatch(/Search tag:(?:\s|)*router start/) }) test('should strip server-only imports used by prerenderParams from client output', () => { diff --git a/e2e/solid-start/basic/tests/prerendering.spec.ts b/e2e/solid-start/basic/tests/prerendering.spec.ts index 8c030122601..c256c0d6007 100644 --- a/e2e/solid-start/basic/tests/prerendering.spec.ts +++ b/e2e/solid-start/basic/tests/prerendering.spec.ts @@ -115,10 +115,8 @@ test.describe('Prerender Static Path Discovery', () => { const html = readFileSync(htmlPath, 'utf-8') expect(html).toContain('Prerendered slug:') expect(html).toContain('with-query') - expect(html).toContain('Search page:') - expect(html).toContain('2') - expect(html).toContain('Search tag:') - expect(html).toContain('router start') + expect(html).toMatch(/Search page:(?:\s|)*2/) + expect(html).toMatch(/Search tag:(?:\s|)*router start/) }) test('should strip server-only imports used by prerenderParams from client output', () => { diff --git a/e2e/vue-start/basic/tests/prerendering.spec.ts b/e2e/vue-start/basic/tests/prerendering.spec.ts index 8c030122601..c256c0d6007 100644 --- a/e2e/vue-start/basic/tests/prerendering.spec.ts +++ b/e2e/vue-start/basic/tests/prerendering.spec.ts @@ -115,10 +115,8 @@ test.describe('Prerender Static Path Discovery', () => { const html = readFileSync(htmlPath, 'utf-8') expect(html).toContain('Prerendered slug:') expect(html).toContain('with-query') - expect(html).toContain('Search page:') - expect(html).toContain('2') - expect(html).toContain('Search tag:') - expect(html).toContain('router start') + expect(html).toMatch(/Search page:(?:\s|)*2/) + expect(html).toMatch(/Search tag:(?:\s|)*router start/) }) test('should strip server-only imports used by prerenderParams from client output', () => { diff --git a/packages/start-plugin-core/src/post-build.ts b/packages/start-plugin-core/src/post-build.ts index 336a7751cac..8864cccb2ce 100644 --- a/packages/start-plugin-core/src/post-build.ts +++ b/packages/start-plugin-core/src/post-build.ts @@ -19,8 +19,7 @@ export async function postBuild({ ...startConfig.prerender, enabled: startConfig.prerender?.enabled ?? - (startConfig.pages.some((page) => page.prerender?.enabled) || - !!globalThis.TSS_PRERENDER_DYNAMIC_ROUTES?.length), + startConfig.pages.some((page) => page.prerender?.enabled), } } diff --git a/packages/start-plugin-core/src/prerender-params-runner.ts b/packages/start-plugin-core/src/prerender-params-runner.ts index f50f4a444c8..d78d7930aea 100644 --- a/packages/start-plugin-core/src/prerender-params-runner.ts +++ b/packages/start-plugin-core/src/prerender-params-runner.ts @@ -138,7 +138,13 @@ async function call( signal.addEventListener('abort', abort, { once: true }) Promise.resolve() - .then(callback) + .then(() => { + if (signal.aborted) { + throw signal.reason ?? new Error('prerenderParams aborted') + } + + return callback() + }) .then(resolve, reject) .finally(() => { signal.removeEventListener('abort', abort) diff --git a/packages/start-plugin-core/tests/post-server-build.test.ts b/packages/start-plugin-core/tests/post-server-build.test.ts index 763f5e5d05d..2edd34b81b5 100644 --- a/packages/start-plugin-core/tests/post-server-build.test.ts +++ b/packages/start-plugin-core/tests/post-server-build.test.ts @@ -11,6 +11,35 @@ vi.mock('../src/build-sitemap', () => ({ })) describe('postServerBuild', () => { + it('does not enable prerendering from dynamic route hints without prerender config', async () => { + const prerender = vi.fn(async () => {}) + const { postBuild } = await import('../src/post-build') + + globalThis.TSS_PRERENDER_DYNAMIC_ROUTES = [ + { routePath: '/posts/$postId', path: '/posts/$postId' }, + ] + + try { + await postBuild({ + startConfig: { + pages: [], + router: { basepath: '' }, + serverFns: { base: '' }, + spa: { enabled: false }, + sitemap: { enabled: false }, + } as any, + adapter: { + getClientOutputDirectory: () => '/client', + prerender, + }, + }) + } finally { + globalThis.TSS_PRERENDER_DYNAMIC_ROUTES = undefined + } + + expect(prerender).not.toHaveBeenCalled() + }) + it('rejects absolute SPA maskPath URLs to avoid external prerendering', async () => { const prerender = vi.fn(async () => {}) const { postBuild } = await import('../src/post-build') From 6f5afd7b7a8368ed3f39a90233e303167829a096 Mon Sep 17 00:00:00 2001 From: jon Date: Tue, 5 May 2026 17:29:36 +0100 Subject: [PATCH 04/11] fix: stabilize prerender adapter builds --- packages/start-plugin-core/src/post-build.ts | 12 ++++++ packages/start-plugin-core/src/prerender.ts | 20 +++++----- .../start-plugin-core/src/vite/prerender.ts | 16 ++++---- .../tests/post-server-build.test.ts | 39 +++++++++++++++++++ 4 files changed, 69 insertions(+), 18 deletions(-) diff --git a/packages/start-plugin-core/src/post-build.ts b/packages/start-plugin-core/src/post-build.ts index 8864cccb2ce..63fa2733781 100644 --- a/packages/start-plugin-core/src/post-build.ts +++ b/packages/start-plugin-core/src/post-build.ts @@ -14,6 +14,9 @@ export async function postBuild({ startConfig: TanStackStartOutputConfig adapter: StartPostBuildAdapter }) { + const spaOnly = + startConfig.spa?.enabled && startConfig.prerender?.enabled !== true + if (startConfig.prerender?.enabled !== false) { startConfig.prerender = { ...startConfig.prerender, @@ -24,8 +27,17 @@ export async function postBuild({ } if (startConfig.spa?.enabled) { + if (spaOnly) { + startConfig.pages = [] + } + startConfig.prerender = { ...startConfig.prerender, + ...(spaOnly + ? { + autoStaticPathsDiscovery: false, + } + : {}), enabled: true, } diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts index b04eefd1d01..16ec53677ad 100644 --- a/packages/start-plugin-core/src/prerender.ts +++ b/packages/start-plugin-core/src/prerender.ts @@ -41,15 +41,17 @@ export async function prerender({ pages = Array.from(pagesMap.values()) } - const routeTree = await globalThis.TSS_PRERENDER_ROUTE_TREE?.() - - pages = await runPrerenderParams({ - routeTree, - pages, - logger, - filter: startConfig.prerender.filter, - prerenderParamsTimeout: startConfig.prerender.prerenderParamsTimeout, - }) + if (!startConfig.spa?.enabled) { + const routeTree = await globalThis.TSS_PRERENDER_ROUTE_TREE?.() + + pages = await runPrerenderParams({ + routeTree, + pages, + logger, + filter: startConfig.prerender.filter, + prerenderParamsTimeout: startConfig.prerender.prerenderParamsTimeout, + }) + } startConfig.pages = pages } diff --git a/packages/start-plugin-core/src/vite/prerender.ts b/packages/start-plugin-core/src/vite/prerender.ts index da426a18635..a89feb6c64f 100644 --- a/packages/start-plugin-core/src/vite/prerender.ts +++ b/packages/start-plugin-core/src/vite/prerender.ts @@ -38,17 +38,15 @@ export async function prerenderWithVite({ const serverInput = getBundlerOptions(serverEnv.config.build)?.input ?? 'server' - if (typeof serverInput !== 'string') { - throw new Error('Invalid server input. Expected a string.') + if (typeof serverInput === 'string') { + // Import the built server entry before prerendering so route options from the + // initialized router are available for dynamic route discovery. + const outputFilename = `${basename(serverInput, extname(serverInput))}.js` + const serverOutputDir = getServerOutputDirectory(serverEnv.config) + const serverEntryPath = join(serverOutputDir, outputFilename) + await import(pathToFileURL(serverEntryPath).toString()) } - // Import the built server entry before prerendering so route options from the - // initialized router are available for dynamic route discovery. - const outputFilename = `${basename(serverInput, extname(serverInput))}.js` - const serverOutputDir = getServerOutputDirectory(serverEnv.config) - const serverEntryPath = join(serverOutputDir, outputFilename) - await import(pathToFileURL(serverEntryPath).toString()) - const previewServer = await startPreviewServer(serverEnv.config) const baseUrl = getResolvedUrl(previewServer) diff --git a/packages/start-plugin-core/tests/post-server-build.test.ts b/packages/start-plugin-core/tests/post-server-build.test.ts index 2edd34b81b5..df98cef4b27 100644 --- a/packages/start-plugin-core/tests/post-server-build.test.ts +++ b/packages/start-plugin-core/tests/post-server-build.test.ts @@ -69,4 +69,43 @@ describe('postServerBuild', () => { expect(prerender).not.toHaveBeenCalled() }) + + it('limits SPA-only prerendering to the shell page', async () => { + const prerender = vi.fn(async () => {}) + const { postBuild } = await import('../src/post-build') + + const startConfig = { + spa: { + enabled: true, + maskPath: '/', + prerender: {}, + }, + pages: [{ path: '/about', prerender: { enabled: true } }], + router: { basepath: '' }, + serverFns: { base: '' }, + sitemap: { enabled: false }, + } as any + + await postBuild({ + startConfig, + adapter: { + getClientOutputDirectory: () => '/client', + prerender, + }, + }) + + expect(prerender).toHaveBeenCalledWith( + expect.objectContaining({ + pages: [ + expect.objectContaining({ + path: '/', + }), + ], + prerender: expect.objectContaining({ + enabled: true, + autoStaticPathsDiscovery: false, + }), + }), + ) + }) }) From fbbe028e197bc60d378b2f885e5faac832f39a38 Mon Sep 17 00:00:00 2001 From: jon Date: Tue, 5 May 2026 21:14:04 +0100 Subject: [PATCH 05/11] fix: isolate prerender route options bundle --- packages/start-plugin-core/src/constants.ts | 1 + packages/start-plugin-core/src/global.d.ts | 6 - packages/start-plugin-core/src/post-build.ts | 6 +- .../src/prerender-route-options-env.ts | 55 +++++ packages/start-plugin-core/src/prerender.ts | 59 ++--- .../start-plugin-core/src/rsbuild/planning.ts | 38 ++++ .../start-plugin-core/src/rsbuild/plugin.ts | 24 +++ .../src/rsbuild/post-build.ts | 64 +++++- .../src/rsbuild/start-compiler-host.ts | 12 +- .../src/rsbuild/start-router-plugin.ts | 15 +- .../start-plugin-core/src/rsbuild/types.ts | 1 + .../src/rsbuild/virtual-modules.ts | 13 +- packages/start-plugin-core/src/schema.ts | 1 + .../src/start-router-plugin/constants.ts | 2 + .../prerender-routes-plugin.ts | 11 - packages/start-plugin-core/src/vite/nitro.ts | 31 +++ .../start-plugin-core/src/vite/planning.ts | 70 ++++-- packages/start-plugin-core/src/vite/plugin.ts | 28 ++- .../start-plugin-core/src/vite/prerender.ts | 203 ++++++++++++++++-- .../src/vite/start-compiler-plugin/plugin.ts | 13 +- .../src/vite/start-router-plugin/plugin.ts | 20 ++ .../tests/post-server-build.test.ts | 79 +++++-- .../tests/prerender-route-options-env.test.ts | 75 +++++++ .../tests/prerender-routes-plugin.test.ts | 47 +--- .../tests/prerender-ssrf.test.ts | 20 ++ .../tests/rsbuild-post-build.test.ts | 120 ++++++++++- .../start-router-plugin-constants.test.ts | 12 +- .../tests/vite-nitro.test.ts | 16 ++ .../tests/vite-planning.test.ts | 37 ++++ .../tests/vite-prerender.test.ts | 162 ++++++++++++++ .../src/createStartHandler.ts | 2 +- 31 files changed, 1074 insertions(+), 169 deletions(-) create mode 100644 packages/start-plugin-core/src/prerender-route-options-env.ts create mode 100644 packages/start-plugin-core/src/vite/nitro.ts create mode 100644 packages/start-plugin-core/tests/prerender-route-options-env.test.ts create mode 100644 packages/start-plugin-core/tests/vite-nitro.test.ts create mode 100644 packages/start-plugin-core/tests/vite-planning.test.ts create mode 100644 packages/start-plugin-core/tests/vite-prerender.test.ts diff --git a/packages/start-plugin-core/src/constants.ts b/packages/start-plugin-core/src/constants.ts index f142bb2881a..c2edd8660e6 100644 --- a/packages/start-plugin-core/src/constants.ts +++ b/packages/start-plugin-core/src/constants.ts @@ -2,6 +2,7 @@ export const START_ENVIRONMENT_NAMES = { // 'ssr' is chosen as the name for the server environment to ensure backwards compatibility // with vite plugins that are not compatible with the new vite environment API (e.g. tailwindcss) server: 'ssr', + prerender: 'prerender', client: 'client', } as const diff --git a/packages/start-plugin-core/src/global.d.ts b/packages/start-plugin-core/src/global.d.ts index e6f6f407f1e..91a859c0188 100644 --- a/packages/start-plugin-core/src/global.d.ts +++ b/packages/start-plugin-core/src/global.d.ts @@ -10,12 +10,6 @@ declare global { } > var TSS_PRERENDABLE_PATHS: Array<{ path: string }> | undefined - var TSS_PRERENDER_DYNAMIC_ROUTES: - | Array<{ - path: string - routePath: string - }> - | undefined var TSS_PRERENDER_ROUTE_TREE: | (() => Promise) | undefined diff --git a/packages/start-plugin-core/src/post-build.ts b/packages/start-plugin-core/src/post-build.ts index 63fa2733781..f4f1e9942ff 100644 --- a/packages/start-plugin-core/src/post-build.ts +++ b/packages/start-plugin-core/src/post-build.ts @@ -14,9 +14,6 @@ export async function postBuild({ startConfig: TanStackStartOutputConfig adapter: StartPostBuildAdapter }) { - const spaOnly = - startConfig.spa?.enabled && startConfig.prerender?.enabled !== true - if (startConfig.prerender?.enabled !== false) { startConfig.prerender = { ...startConfig.prerender, @@ -26,6 +23,9 @@ export async function postBuild({ } } + const spaOnly = + startConfig.spa?.enabled && startConfig.prerender.enabled !== true + if (startConfig.spa?.enabled) { if (spaOnly) { startConfig.pages = [] diff --git a/packages/start-plugin-core/src/prerender-route-options-env.ts b/packages/start-plugin-core/src/prerender-route-options-env.ts new file mode 100644 index 00000000000..b07f739eab3 --- /dev/null +++ b/packages/start-plugin-core/src/prerender-route-options-env.ts @@ -0,0 +1,55 @@ +import type { TanStackStartOutputConfig } from './schema' + +export interface PrerenderEnvState { + prerendering: string | undefined + clientOutputDir: string | undefined +} + +export function capturePrerenderEnv(): PrerenderEnvState { + return { + prerendering: process.env.TSS_PRERENDERING, + clientOutputDir: process.env.TSS_CLIENT_OUTPUT_DIR, + } +} + +export function restorePrerenderEnv(state: PrerenderEnvState) { + if (state.prerendering === undefined) { + delete process.env.TSS_PRERENDERING + } else { + process.env.TSS_PRERENDERING = state.prerendering + } + + if (state.clientOutputDir === undefined) { + delete process.env.TSS_CLIENT_OUTPUT_DIR + } else { + process.env.TSS_CLIENT_OUTPUT_DIR = state.clientOutputDir + } +} + +export function applySeparatePrerenderRouteOptionsBundleDefault( + startConfig: TanStackStartOutputConfig, + defaultValue: boolean, +) { + if (startConfig.prerender?.separateRouteOptionsBundle !== undefined) { + return + } + + startConfig.prerender = { + ...startConfig.prerender, + separateRouteOptionsBundle: defaultValue, + } +} + +export function shouldUseSeparatePrerenderRouteOptions( + startConfig: TanStackStartOutputConfig, +) { + if (startConfig.prerender?.separateRouteOptionsBundle === false) { + return false + } + + const prerenderEnabled = + startConfig.prerender?.enabled ?? + startConfig.pages.some((page) => page.prerender?.enabled) + + return prerenderEnabled && !startConfig.spa?.enabled +} diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts index 16ec53677ad..dd70314cc57 100644 --- a/packages/start-plugin-core/src/prerender.ts +++ b/packages/start-plugin-core/src/prerender.ts @@ -25,46 +25,46 @@ export async function prerender({ const logger = createLogger('prerender') logger.info('Prerendering pages...') - if (startConfig.prerender?.enabled) { - let pages = startConfig.pages.length ? startConfig.pages : [{ path: '/' }] + try { + if (startConfig.prerender?.enabled) { + let pages = startConfig.pages.length ? startConfig.pages : [{ path: '/' }] - if (startConfig.prerender.autoStaticPathsDiscovery ?? true) { - const pagesMap = new Map(pages.map((item) => [item.path, item])) - const discoveredPages = globalThis.TSS_PRERENDABLE_PATHS || [] + if (startConfig.prerender.autoStaticPathsDiscovery ?? true) { + const pagesMap = new Map(pages.map((item) => [item.path, item])) + const discoveredPages = globalThis.TSS_PRERENDABLE_PATHS || [] - for (const page of discoveredPages) { - if (!pagesMap.has(page.path)) { - pagesMap.set(page.path, page) + for (const page of discoveredPages) { + if (!pagesMap.has(page.path)) { + pagesMap.set(page.path, page) + } } + + pages = Array.from(pagesMap.values()) } - pages = Array.from(pagesMap.values()) - } + if (!startConfig.spa?.enabled) { + const routeTree = await globalThis.TSS_PRERENDER_ROUTE_TREE?.() - if (!startConfig.spa?.enabled) { - const routeTree = await globalThis.TSS_PRERENDER_ROUTE_TREE?.() + pages = await runPrerenderParams({ + routeTree, + pages, + logger, + filter: startConfig.prerender.filter, + prerenderParamsTimeout: startConfig.prerender.prerenderParamsTimeout, + }) + } - pages = await runPrerenderParams({ - routeTree, - pages, - logger, - filter: startConfig.prerender.filter, - prerenderParamsTimeout: startConfig.prerender.prerenderParamsTimeout, - }) + startConfig.pages = pages } - startConfig.pages = pages - } - - const routerBasePath = joinURL('/', startConfig.router.basepath ?? '') - const routerBaseUrl = new URL(routerBasePath, 'http://localhost') + const routerBasePath = joinURL('/', startConfig.router.basepath ?? '') + const routerBaseUrl = new URL(routerBasePath, 'http://localhost') - startConfig.pages = validateAndNormalizePrerenderPages( - startConfig.pages, - routerBaseUrl, - ) + startConfig.pages = validateAndNormalizePrerenderPages( + startConfig.pages, + routerBaseUrl, + ) - try { const pages = await prerenderPages({ outputDir: handler.getClientOutputDirectory(), }) @@ -77,6 +77,7 @@ export async function prerender({ logger.error(error) throw error } finally { + delete globalThis.TSS_PRERENDER_ROUTE_TREE await handler.close?.() } diff --git a/packages/start-plugin-core/src/rsbuild/planning.ts b/packages/start-plugin-core/src/rsbuild/planning.ts index 5ab3b631f2e..cb05aad5d97 100644 --- a/packages/start-plugin-core/src/rsbuild/planning.ts +++ b/packages/start-plugin-core/src/rsbuild/planning.ts @@ -11,6 +11,7 @@ const require = createRequire(import.meta.url) export const RSBUILD_ENVIRONMENT_NAMES = { client: 'client', server: 'ssr', + prerender: 'prerender', } as const /** @@ -71,6 +72,7 @@ export function createRsbuildEnvironmentPlan(opts: { serverOutputDirectory: string publicBase: string serverFnProviderEnv: string + separatePrerenderRouteOptions: boolean environmentOverrides?: RsbuildEnvironmentOverrides rsc?: boolean | undefined dev?: boolean | undefined @@ -161,6 +163,42 @@ export function createRsbuildEnvironmentPlan(opts: { environmentOverrides.all, environmentOverrides.server, ), + ...(opts.separatePrerenderRouteOptions + ? { + [RSBUILD_ENVIRONMENT_NAMES.prerender]: mergeRsbuildConfig( + { + source: { + entry: { + index: { + import: opts.entryAliases.server, + html: false, + ...(opts.rsc ? { layer: RSBUILD_RSC_LAYERS.ssr } : {}), + }, + }, + }, + output: { + target: 'node', + module: true, + distPath: { + root: `${opts.serverOutputDirectory}/.tanstack/prerender`, + }, + }, + resolve: { + alias, + }, + ...(opts.rsc + ? { + splitChunks: { + preset: 'single-vendor', + }, + } + : {}), + }, + environmentOverrides.all, + environmentOverrides.prerender, + ), + } + : {}), // When provider is a separate environment (not layered RSC), // create a third environment. With the layered RSC setup this branch // is not taken because provider maps to the same `ssr` environment. diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts index 08d299e507d..7e261e6ca52 100644 --- a/packages/start-plugin-core/src/rsbuild/plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/plugin.ts @@ -10,6 +10,7 @@ import { } from '../config-context' import { normalizePath } from '../utils' import { createServerFnBasePath, normalizePublicBase } from '../planning' +import { shouldUseSeparatePrerenderRouteOptions } from '../prerender-route-options-env' import { parseStartConfig } from './schema' import { RSBUILD_ENVIRONMENT_NAMES, @@ -86,6 +87,7 @@ export function tanStackStartRsbuild( let devServerRef: Pick | null = null const serverFnsById: Record = {} let updateServerFnResolver: (() => void) | undefined + let prerenderOutputDirectory: string | undefined return { name: 'tanstack-start-rsbuild', @@ -154,10 +156,23 @@ export function tanStackStartRsbuild( serverOutputDirectory: resolvedStartConfig.outputDirectories.server, publicBase: resolvedStartConfig.basePaths.publicBase, serverFnProviderEnv, + separatePrerenderRouteOptions: + shouldUseSeparatePrerenderRouteOptions(startConfig), environmentOverrides: corePluginOpts.rsbuild?.environments, rsc: rscOpts, dev: isDev, }) + prerenderOutputDirectory = resolveRsbuildOutputDirectory({ + distPath: + environmentPlan.environments[RSBUILD_ENVIRONMENT_NAMES.prerender] + ?.output?.distPath, + rootDistPath: undefined, + fallback: join( + resolvedStartConfig.outputDirectories.server, + '.tanstack/prerender', + ), + subdirectory: 'prerender', + }) const serverFnBase = createServerFnBasePath({ routerBasepath, serverFnBase: startConfig.serverFns.base, @@ -233,6 +248,11 @@ export function tanStackStartRsbuild( // --------------------------------------------------------------- registerStartCompilerTransforms(api, { framework: corePluginOpts.framework, + environments: [ + { name: RSBUILD_ENVIRONMENT_NAMES.client, type: 'client' }, + { name: RSBUILD_ENVIRONMENT_NAMES.prerender, type: 'server' }, + { name: RSBUILD_ENVIRONMENT_NAMES.server, type: 'server' }, + ], // modifyRsbuildConfig copies rsbuildConfig.root into resolvedStartConfig.root, // so defer this read until transform time instead of falling back to // process.cwd() during plugin setup. @@ -253,6 +273,7 @@ export function tanStackStartRsbuild( framework: corePluginOpts.framework, environments: [ { name: RSBUILD_ENVIRONMENT_NAMES.client, type: 'client' }, + { name: RSBUILD_ENVIRONMENT_NAMES.prerender, type: 'server' }, { name: RSBUILD_ENVIRONMENT_NAMES.server, type: 'server' }, ...(serverFnProviderEnv !== RSBUILD_ENVIRONMENT_NAMES.server && !rscEnabled @@ -675,6 +696,9 @@ export function tanStackStartRsbuild( startConfig, clientOutputDirectory: resolvedStartConfig.outputDirectories.client, serverOutputDirectory: resolvedStartConfig.outputDirectories.server, + prerenderOutputDirectory, + separatePrerenderRouteOptions: + shouldUseSeparatePrerenderRouteOptions(startConfig), }) }) } diff --git a/packages/start-plugin-core/src/rsbuild/post-build.ts b/packages/start-plugin-core/src/rsbuild/post-build.ts index 7464c286515..4c1ae0ec621 100644 --- a/packages/start-plugin-core/src/rsbuild/post-build.ts +++ b/packages/start-plugin-core/src/rsbuild/post-build.ts @@ -1,6 +1,11 @@ +import { promises as fsp } from 'node:fs' import { join } from 'pathe' import { postBuild } from '../post-build' import { prerender } from '../prerender' +import { + capturePrerenderEnv, + restorePrerenderEnv, +} from '../prerender-route-options-env' import type { PrerenderHandler } from '../prerender' import type { TanStackStartOutputConfig } from '../schema' @@ -8,10 +13,14 @@ export async function postBuildWithRsbuild({ startConfig, clientOutputDirectory, serverOutputDirectory, + prerenderOutputDirectory, + separatePrerenderRouteOptions, }: { startConfig: TanStackStartOutputConfig clientOutputDirectory: string serverOutputDirectory: string + prerenderOutputDirectory?: string | undefined + separatePrerenderRouteOptions: boolean }) { await postBuild({ startConfig, @@ -23,8 +32,16 @@ export async function postBuildWithRsbuild({ const handler = createRsbuildPrerenderHandler({ clientOutputDirectory, serverOutputDirectory, + prerenderOutputDirectory, + separatePrerenderRouteOptions, }) - await handler.loadRequestHandler() + try { + await handler.loadRouteOptions() + await handler.loadRequestHandler() + } catch (error) { + await handler.close?.() + throw error + } return prerender({ startConfig, @@ -38,14 +55,21 @@ export async function postBuildWithRsbuild({ function createRsbuildPrerenderHandler({ clientOutputDirectory, serverOutputDirectory, + prerenderOutputDirectory, + separatePrerenderRouteOptions, }: { clientOutputDirectory: string serverOutputDirectory: string + prerenderOutputDirectory?: string | undefined + separatePrerenderRouteOptions: boolean }): PrerenderHandler & { + loadRouteOptions: () => Promise loadRequestHandler: () => Promise< (request: Request, opts?: unknown) => Promise | Response > } { + const prerenderEnvState = capturePrerenderEnv() + process.env.TSS_PRERENDERING = 'true' process.env.TSS_CLIENT_OUTPUT_DIR = clientOutputDirectory @@ -55,7 +79,10 @@ function createRsbuildPrerenderHandler({ > | undefined + let routeOptionsPromise: Promise | undefined + return { + loadRouteOptions, loadRequestHandler, getClientOutputDirectory() { return clientOutputDirectory @@ -71,6 +98,16 @@ function createRsbuildPrerenderHandler({ }), ) }, + async close() { + delete globalThis.TSS_PRERENDER_ROUTE_TREE + restorePrerenderEnv(prerenderEnvState) + if (separatePrerenderRouteOptions) { + await fsp.rm(getPrerenderOutputDirectory(), { + recursive: true, + force: true, + }) + } + }, } function loadRequestHandler() { @@ -82,6 +119,31 @@ function createRsbuildPrerenderHandler({ return requestHandlerPromise } + + function loadRouteOptions() { + if (!routeOptionsPromise) { + routeOptionsPromise = separatePrerenderRouteOptions + ? loadRouteOptionsFromBundle(getPrerenderOutputDirectory()) + : loadRequestHandler().then(() => undefined) + } + + return routeOptionsPromise + } + + function getPrerenderOutputDirectory() { + return ( + prerenderOutputDirectory ?? join(serverOutputDirectory, '.tanstack/prerender') + ) + } +} + +async function loadRouteOptionsFromBundle(prerenderOutputDirectory: string) { + const { pathToFileURL } = await import('node:url') + const prerenderEntryUrl = pathToFileURL(join(prerenderOutputDirectory, 'index.js')) + prerenderEntryUrl.searchParams.set('tss-prerender', Date.now().toString()) + + delete globalThis.TSS_PRERENDER_ROUTE_TREE + await import(prerenderEntryUrl.toString()) } async function loadRequestHandlerFromBundle(serverOutputDirectory: string) { diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts index 066d5b28676..e563b867f78 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts @@ -38,6 +38,10 @@ export interface StartCompilerHostOptions { framework: CompileStartFrameworkOptions root: string | (() => string) providerEnvName: string + environments?: Array<{ + name: string + type: 'client' | 'server' + }> generateFunctionId?: GenerateFunctionIdFnOptional compilerTransforms?: Array | undefined serverFnProviderModuleDirectives?: ReadonlyArray | undefined @@ -73,10 +77,7 @@ export function registerStartCompilerTransforms( const isDev = api.context.action === 'dev' const mode = isDev ? 'dev' : 'build' - const environments: Array<{ - name: string - type: 'client' | 'server' - }> = [ + const environments = opts.environments ?? [ { name: RSBUILD_ENVIRONMENT_NAMES.client, type: 'client' }, { name: RSBUILD_ENVIRONMENT_NAMES.server, type: 'server' }, ] @@ -92,7 +93,8 @@ export function registerStartCompilerTransforms( for (const env of environments) { const envCodeFilters = codeFilters[env.type] const compilerTransforms = - env.name === RSBUILD_ENVIRONMENT_NAMES.server + env.name === RSBUILD_ENVIRONMENT_NAMES.server || + env.name === RSBUILD_ENVIRONMENT_NAMES.prerender ? opts.compilerTransforms : undefined const serverFnProviderModuleDirectives = diff --git a/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts b/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts index 377d4ab56e5..a9dc02e9a78 100644 --- a/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts @@ -7,7 +7,11 @@ import { import { routesManifestPlugin } from '../start-router-plugin/generator-plugins/routes-manifest-plugin' import { prerenderRoutesPlugin } from '../start-router-plugin/generator-plugins/prerender-routes-plugin' import { buildRouteTreeFileFooterFromConfig } from '../start-router-plugin/route-tree-footer' -import { CLIENT_ROUTE_OPTION_DELETE_NODES } from '../start-router-plugin/constants' +import { + CLIENT_ROUTE_OPTION_DELETE_NODES, + SERVER_ROUTE_OPTION_DELETE_NODES, +} from '../start-router-plugin/constants' +import { shouldUseSeparatePrerenderRouteOptions } from '../prerender-route-options-env' import { RSBUILD_ENVIRONMENT_NAMES } from './planning' import type { RsbuildPluginAPI } from '@rsbuild/core' import type { GetConfigFn, TanStackStartCoreOptions } from '../types' @@ -65,9 +69,11 @@ export function registerRouterPlugins( if ( envName === RSBUILD_ENVIRONMENT_NAMES.client || - envName === RSBUILD_ENVIRONMENT_NAMES.server + envName === RSBUILD_ENVIRONMENT_NAMES.server || + envName === RSBUILD_ENVIRONMENT_NAMES.prerender ) { const isClient = envName === RSBUILD_ENVIRONMENT_NAMES.client + const isServer = envName === RSBUILD_ENVIRONMENT_NAMES.server const splitterPlugin = TanStackRouterCodeSplitterRspack( { ...routerConfig, @@ -76,7 +82,10 @@ export function registerRouterPlugins( ...routerConfig.codeSplittingOptions, deleteNodes: isClient ? CLIENT_ROUTE_OPTION_DELETE_NODES - : undefined, + : isServer && + shouldUseSeparatePrerenderRouteOptions(startConfig) + ? SERVER_ROUTE_OPTION_DELETE_NODES + : undefined, addHmr: isClient, }, }, diff --git a/packages/start-plugin-core/src/rsbuild/types.ts b/packages/start-plugin-core/src/rsbuild/types.ts index fa10d65f640..4be276b5b04 100644 --- a/packages/start-plugin-core/src/rsbuild/types.ts +++ b/packages/start-plugin-core/src/rsbuild/types.ts @@ -5,6 +5,7 @@ export interface RsbuildEnvironmentOverrides { all?: EnvironmentConfig | undefined client?: EnvironmentConfig | undefined server?: EnvironmentConfig | undefined + prerender?: EnvironmentConfig | undefined provider?: EnvironmentConfig | undefined } diff --git a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts index 2f7d92a4d47..fadd5e2d40d 100644 --- a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts +++ b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts @@ -271,6 +271,7 @@ export function registerVirtualModules( function needsServerFnResolver(environmentName: string): boolean { return ( environmentName === RSBUILD_ENVIRONMENT_NAMES.server || + environmentName === RSBUILD_ENVIRONMENT_NAMES.prerender || (hasSeparateProviderEnvironment && isProviderEnvironment(environmentName)) ) } @@ -353,6 +354,9 @@ export function registerVirtualModules( // Safe to call getConfig() here — this runs inside modifyRspackConfig const { resolvedStartConfig, startConfig } = opts.getConfig() const isServerEnv = environmentName === RSBUILD_ENVIRONMENT_NAMES.server + const isPrerenderEnv = + environmentName === RSBUILD_ENVIRONMENT_NAMES.prerender + const isServerLikeEnv = isServerEnv || isPrerenderEnv const isClientEnv = environmentName === RSBUILD_ENVIRONMENT_NAMES.client const content: Record = {} @@ -370,7 +374,7 @@ export function registerVirtualModules( startConfig.server.build.inlineCss, ) } else { - content[paths.manifest] = 'export default {}' + content[paths.manifest] = `export const tsrStartManifest = () => ({ routes: {}, clientEntry: '' })` } // Injected head scripts — only server @@ -401,8 +405,8 @@ export function registerVirtualModules( // rspack layers handle module isolation. The RSC entry imports this // and the react-server condition on the RSC layer resolves // react-server-dom-rspack/server correctly. - if (isServerEnv) { - // Server env gets the real RSC runtime (used by RSC layer) + if (isServerLikeEnv) { + // Server-like envs get the real RSC runtime (used by RSC layer) content[rscPaths.rscRuntime] = generateRscRuntimeModule(true) } else { // Client env gets stubs @@ -415,7 +419,7 @@ export function registerVirtualModules( ? `export * from '@tanstack/react-start/rsbuild/browser-decode'` : `export function createFromReadableStream() { throw new Error('RSC browser decode is only available in the client environment') } export function createFromFetch() { throw new Error('RSC browser decode is only available in the client environment') }` - content[rscPaths.rscSsrDecode] = isServerEnv + content[rscPaths.rscSsrDecode] = isServerLikeEnv ? `export * from '@tanstack/react-start/rsbuild/ssr-decode'` : `export function setOnClientReference() {} export function createFromReadableStream() { throw new Error('RSC SSR decode is only available in the server environment') }` @@ -545,6 +549,7 @@ export function createFromReadableStream() { throw new Error('RSC SSR decode is updateServerFnResolver() { for (const environmentName of new Set([ RSBUILD_ENVIRONMENT_NAMES.server, + RSBUILD_ENVIRONMENT_NAMES.prerender, ...(hasSeparateProviderEnvironment ? [opts.providerEnvName] : []), ])) { if (!needsServerFnResolver(environmentName)) { diff --git a/packages/start-plugin-core/src/schema.ts b/packages/start-plugin-core/src/schema.ts index 6a08120d37c..1840069bbf5 100644 --- a/packages/start-plugin-core/src/schema.ts +++ b/packages/start-plugin-core/src/schema.ts @@ -252,6 +252,7 @@ export const tanstackStartOptionsObjectSchema = z.object({ filter: z.function().args(pageSchema).returns(z.any()).optional(), failOnError: z.boolean().optional(), autoStaticPathsDiscovery: z.boolean().optional(), + separateRouteOptionsBundle: z.boolean().optional(), maxRedirects: z.number().min(0).optional(), prerenderParamsTimeout: z.number().min(0).optional(), }) diff --git a/packages/start-plugin-core/src/start-router-plugin/constants.ts b/packages/start-plugin-core/src/start-router-plugin/constants.ts index 273ae471884..253f977c7d4 100644 --- a/packages/start-plugin-core/src/start-router-plugin/constants.ts +++ b/packages/start-plugin-core/src/start-router-plugin/constants.ts @@ -6,3 +6,5 @@ export const CLIENT_ROUTE_OPTION_DELETE_NODES = [ 'prerenderParams', 'sitemap', ] + +export const SERVER_ROUTE_OPTION_DELETE_NODES = ['prerenderParams', 'sitemap'] diff --git a/packages/start-plugin-core/src/start-router-plugin/generator-plugins/prerender-routes-plugin.ts b/packages/start-plugin-core/src/start-router-plugin/generator-plugins/prerender-routes-plugin.ts index c1e5d398743..b614ae4afae 100644 --- a/packages/start-plugin-core/src/start-router-plugin/generator-plugins/prerender-routes-plugin.ts +++ b/packages/start-plugin-core/src/start-router-plugin/generator-plugins/prerender-routes-plugin.ts @@ -10,17 +10,6 @@ export function prerenderRoutesPlugin(): GeneratorPlugin { name: 'prerender-routes-plugin', onRouteTreeChanged: ({ routeNodes }) => { globalThis.TSS_PRERENDABLE_PATHS = getPrerenderablePaths(routeNodes) - const seenDynamicRoutes = new Set() - - globalThis.TSS_PRERENDER_DYNAMIC_ROUTES = routeNodes.flatMap((route) => { - if (!route.routePath) return [] - if (!route.createFileRouteProps?.has('prerenderParams')) return [] - if (seenDynamicRoutes.has(route.routePath)) return [] - - seenDynamicRoutes.add(route.routePath) - - return [{ path: inferFullPath(route), routePath: route.routePath }] - }) }, } } diff --git a/packages/start-plugin-core/src/vite/nitro.ts b/packages/start-plugin-core/src/vite/nitro.ts new file mode 100644 index 00000000000..ed04f5fb117 --- /dev/null +++ b/packages/start-plugin-core/src/vite/nitro.ts @@ -0,0 +1,31 @@ +import type { PluginOption } from 'vite' + +export function hasNitroPlugin( + plugins: PluginOption | Array | undefined, +) { + if (!plugins) { + return false + } + + for (const plugin of Array.isArray(plugins) ? plugins : [plugins]) { + if (!plugin) { + continue + } + + if (Array.isArray(plugin)) { + if (hasNitroPlugin(plugin)) { + return true + } + continue + } + + if (typeof plugin === 'object' && 'name' in plugin) { + const name = plugin.name + if (typeof name === 'string' && name.startsWith('nitro:')) { + return true + } + } + } + + return false +} diff --git a/packages/start-plugin-core/src/vite/planning.ts b/packages/start-plugin-core/src/vite/planning.ts index 5db724ba8df..06ac6eae54f 100644 --- a/packages/start-plugin-core/src/vite/planning.ts +++ b/packages/start-plugin-core/src/vite/planning.ts @@ -44,9 +44,17 @@ export function createViteConfigPlan(opts: { clientOutputDirectory: string serverOutputDirectory: string serverFnProviderEnv: string + separatePrerenderRouteOptions: boolean optimizeDepsExclude: Array noExternal: Array }) { + const serverInput = + getBundlerOptions( + opts.viteConfig.environments?.[START_ENVIRONMENT_NAMES.server]?.build, + )?.input ?? opts.entryAliases.server + const prerenderInput = + typeof serverInput === 'string' ? { server: serverInput } : serverInput + return { environments: { [START_ENVIRONMENT_NAMES.client]: { @@ -75,19 +83,7 @@ export function createViteConfigPlan(opts: { consumer: 'server', build: { ssr: true, - ...(() => { - const bundlerOptions = { - input: - getBundlerOptions( - opts.viteConfig.environments?.[START_ENVIRONMENT_NAMES.server] - ?.build, - )?.input ?? opts.entryAliases.server, - } - return { - rollupOptions: bundlerOptions, - rolldownOptions: bundlerOptions, - } - })(), + ...buildViteInputOptions(serverInput), outDir: opts.serverOutputDirectory, commonjsOptions: { include: [/node_modules/], @@ -104,6 +100,32 @@ export function createViteConfigPlan(opts: { ]), }, }, + ...(opts.separatePrerenderRouteOptions + ? { + [START_ENVIRONMENT_NAMES.prerender]: { + consumer: 'server', + build: { + ssr: true, + ...buildViteInputOptions(prerenderInput), + outDir: join( + opts.serverOutputDirectory, + '.tanstack/prerender', + ), + commonjsOptions: { + include: [/node_modules/], + }, + copyPublicDir: false, + }, + optimizeDeps: { + entries: escapeEntries([ + opts.entryAliases.server, + opts.entryAliases.start, + opts.entryAliases.router, + ]), + }, + }, + } + : {}), ...(opts.serverFnProviderEnv !== START_ENVIRONMENT_NAMES.server && { [opts.serverFnProviderEnv]: { build: { @@ -175,6 +197,7 @@ export async function buildStartViteEnvironments(opts: { builder: vite.ViteBuilder providerEnvironmentName: string ssrIsProvider: boolean + separatePrerenderRouteOptions: boolean }) { const client = getRequiredBuilderEnvironment( opts.builder, @@ -195,6 +218,18 @@ export async function buildStartViteEnvironments(opts: { await opts.builder.build(server) } + if (opts.separatePrerenderRouteOptions) { + const prerender = getRequiredBuilderEnvironment( + opts.builder, + START_ENVIRONMENT_NAMES.prerender, + 'Prerender environment not found', + ) + + if (!prerender.isBuilt) { + await opts.builder.build(prerender) + } + } + if (opts.ssrIsProvider) { return } @@ -214,6 +249,15 @@ function escapeEntries(entries: Array) { return entries.map((entry) => escapePath(entry)) } +function buildViteInputOptions(input: NonNullable['input']) { + const bundlerOptions = { input } + + return { + rollupOptions: bundlerOptions, + rolldownOptions: bundlerOptions, + } +} + function defineReplaceEnv( key: TKey, value: TValue, diff --git a/packages/start-plugin-core/src/vite/plugin.ts b/packages/start-plugin-core/src/vite/plugin.ts index 915fcd7acae..ef9656fb9e4 100644 --- a/packages/start-plugin-core/src/vite/plugin.ts +++ b/packages/start-plugin-core/src/vite/plugin.ts @@ -10,6 +10,10 @@ import { normalizePublicBase, shouldRewriteDevBasepath, } from '../planning' +import { + applySeparatePrerenderRouteOptionsBundleDefault, + shouldUseSeparatePrerenderRouteOptions, +} from '../prerender-route-options-env' import { importProtectionPlugin } from './import-protection-plugin/plugin' import { startCompilerPlugin } from './start-compiler-plugin/plugin' import { loadEnvPlugin } from './load-env-plugin/plugin' @@ -33,6 +37,7 @@ import { getClientOutputDirectory, getServerOutputDirectory, } from './output-directory' +import { hasNitroPlugin } from './nitro' import { postServerBuild } from './post-server-build' import { serializationAdaptersPlugin } from './serialization-adapters-plugin' import type { @@ -61,19 +66,26 @@ export function tanStackStartVite( // we install a URL rewrite middleware instead of erroring. let needsDevBaseRewrite = false + const getServerFnById = + corePluginOpts.ssrResolverStrategy.type === 'vite-rsc-forward' + ? createViteRscForwarder(corePluginOpts.ssrResolverStrategy) + : undefined + const environments: Array<{ name: string type: 'client' | 'server' getServerFnById?: string }> = [ { name: START_ENVIRONMENT_NAMES.client, type: 'client' }, + { + name: START_ENVIRONMENT_NAMES.prerender, + type: 'server', + getServerFnById, + }, { name: START_ENVIRONMENT_NAMES.server, type: 'server', - getServerFnById: - corePluginOpts.ssrResolverStrategy.type === 'vite-rsc-forward' - ? createViteRscForwarder(corePluginOpts.ssrResolverStrategy) - : undefined, + getServerFnById, }, ] if ( @@ -99,6 +111,10 @@ export function tanStackStartVite( serverOutputDirectory: getServerOutputDirectory(viteConfig), }) const { startConfig } = getConfig() + applySeparatePrerenderRouteOptionsBundleDefault( + startConfig, + !hasNitroPlugin(viteConfig.plugins), + ) const routerBasepath = applyResolvedRouterBasepath({ resolvedStartConfig, startConfig, @@ -165,6 +181,8 @@ export function tanStackStartVite( clientOutputDirectory: resolvedStartConfig.outputDirectories.client, serverOutputDirectory: resolvedStartConfig.outputDirectories.server, serverFnProviderEnv, + separatePrerenderRouteOptions: + shouldUseSeparatePrerenderRouteOptions(startConfig), optimizeDepsExclude: crawlFrameworkPkgsResult.optimizeDeps.exclude, noExternal: crawlFrameworkPkgsResult.ssr.noExternal.sort(), }) @@ -196,6 +214,8 @@ export function tanStackStartVite( builder, providerEnvironmentName: serverFnProviderEnv, ssrIsProvider, + separatePrerenderRouteOptions: + shouldUseSeparatePrerenderRouteOptions(startConfig), }) }, }, diff --git a/packages/start-plugin-core/src/vite/prerender.ts b/packages/start-plugin-core/src/vite/prerender.ts index a89feb6c64f..f40db29ea18 100644 --- a/packages/start-plugin-core/src/vite/prerender.ts +++ b/packages/start-plugin-core/src/vite/prerender.ts @@ -1,11 +1,18 @@ +import { promises as fsp } from 'node:fs' import { pathToFileURL } from 'node:url' import { basename, extname, join } from 'pathe' import { VITE_ENVIRONMENT_NAMES } from '../constants' import { prerender } from '../prerender' +import { + capturePrerenderEnv, + restorePrerenderEnv, + shouldUseSeparatePrerenderRouteOptions, +} from '../prerender-route-options-env' import { getBundlerOptions } from '../utils' import { getServerOutputDirectory } from './output-directory' import type { TanStackStartOutputConfig } from '../schema' import type { PrerenderHandler } from '../prerender' +import type { Dirent } from 'node:fs' import type { PreviewServer, ResolvedConfig, ViteBuilder } from 'vite' export async function prerenderWithVite({ @@ -31,24 +38,33 @@ export async function prerenderWithVite({ } const outputDir = clientEnv.config.build.outDir + const prerenderEnvState = capturePrerenderEnv() process.env.TSS_PRERENDERING = 'true' process.env.TSS_CLIENT_OUTPUT_DIR = outputDir - const serverInput = - getBundlerOptions(serverEnv.config.build)?.input ?? 'server' + let routeOptionsOutputDir: string | undefined + let previewServer: PreviewServer | undefined + let baseUrl: URL - if (typeof serverInput === 'string') { - // Import the built server entry before prerendering so route options from the - // initialized router are available for dynamic route discovery. - const outputFilename = `${basename(serverInput, extname(serverInput))}.js` - const serverOutputDir = getServerOutputDirectory(serverEnv.config) - const serverEntryPath = join(serverOutputDir, outputFilename) - await import(pathToFileURL(serverEntryPath).toString()) - } + try { + routeOptionsOutputDir = await importRouteOptionsEntry({ + startConfig, + serverEnv, + prerenderEnv: builder.environments[VITE_ENVIRONMENT_NAMES.prerender], + }) - const previewServer = await startPreviewServer(serverEnv.config) - const baseUrl = getResolvedUrl(previewServer) + previewServer = await startPreviewServer(serverEnv.config) + baseUrl = getResolvedUrl(previewServer) + } catch (error) { + delete globalThis.TSS_PRERENDER_ROUTE_TREE + restorePrerenderEnv(prerenderEnvState) + await previewServer?.close() + if (routeOptionsOutputDir) { + await fsp.rm(routeOptionsOutputDir, { recursive: true, force: true }) + } + throw error + } const handler: PrerenderHandler = { getClientOutputDirectory() { @@ -58,8 +74,13 @@ export async function prerenderWithVite({ const url = new URL(path, baseUrl) return fetch(new Request(url, options)) }, - close() { - return previewServer.close() + async close() { + delete globalThis.TSS_PRERENDER_ROUTE_TREE + restorePrerenderEnv(prerenderEnvState) + await previewServer.close() + if (routeOptionsOutputDir) { + await fsp.rm(routeOptionsOutputDir, { recursive: true, force: true }) + } }, } @@ -69,6 +90,160 @@ export async function prerenderWithVite({ }) } +async function importRouteOptionsEntry({ + startConfig, + serverEnv, + prerenderEnv, +}: { + startConfig: TanStackStartOutputConfig + serverEnv: NonNullable + prerenderEnv: ViteBuilder['environments'][string] | undefined +}): Promise { + const separateRouteOptions = shouldUseSeparatePrerenderRouteOptions(startConfig) + const routeOptionsEnv = separateRouteOptions ? prerenderEnv : serverEnv + + if (!routeOptionsEnv) { + throw new Error( + `Vite's "${VITE_ENVIRONMENT_NAMES.prerender}" environment not found`, + ) + } + + const entry = getRouteOptionsEntry( + getBundlerOptions(routeOptionsEnv.config.build)?.input ?? 'server', + ) + + if (!entry) { + return undefined + } + + delete globalThis.TSS_PRERENDER_ROUTE_TREE + + // Import the prerender-only entry before crawling so route options from the + // initialized router are available for dynamic route discovery. + const outputDir = separateRouteOptions + ? routeOptionsEnv.config.build.outDir + : getServerOutputDirectory(serverEnv.config) + const entryPath = await resolveRouteOptionsEntryPath(outputDir, entry.outputName) + + try { + await importWithCacheBust(entryPath) + } catch (error) { + if (separateRouteOptions) { + await fsp.rm(outputDir, { recursive: true, force: true }) + } + throw error + } + + return separateRouteOptions ? outputDir : undefined +} + +async function resolveRouteOptionsEntryPath( + outputDir: string, + outputName: string, +) { + for (const ext of ['.js', '.mjs']) { + const entryPath = join(outputDir, `${outputName}${ext}`) + try { + await fsp.access(entryPath) + return entryPath + } catch { + // Try the next common Vite SSR extension before scanning below. + } + } + + const entryPath = await findEntryFile(outputDir, outputName) + if (entryPath) { + return entryPath + } + + throw new Error( + `Unable to resolve Vite route-options entry ${outputName} in ${outputDir}`, + ) +} + +async function findEntryFile( + directory: string, + outputName: string, +): Promise { + let entries: Array + + try { + entries = await fsp.readdir(directory, { withFileTypes: true }) + } catch { + return undefined + } + + const matches = new Set() + + for (const entry of entries) { + const entryPath = join(directory, entry.name) + + if (entry.isDirectory()) { + const match = await findEntryFile(entryPath, outputName) + if (match) { + matches.add(match) + } + continue + } + + const ext = extname(entry.name) + if (ext !== '.js' && ext !== '.mjs') { + continue + } + + const name = basename(entry.name, ext) + if (name === outputName || name.startsWith(`${outputName}-`)) { + matches.add(entryPath) + } + } + + if (matches.size === 1) { + return Array.from(matches)[0] + } + + if (matches.size > 1) { + throw new Error( + `Unable to resolve a unique Vite route-options entry ${outputName} in ${directory}`, + ) + } + + return undefined +} + +async function importWithCacheBust(path: string) { + const url = pathToFileURL(path) + url.searchParams.set('tss-prerender', Date.now().toString()) + await import(url.toString()) +} + +function getRouteOptionsEntry( + input: unknown, +): { outputName: string } | undefined { + if (typeof input === 'string') { + return { outputName: basename(input, extname(input)) } + } + + if (input && typeof input === 'object') { + const entries = Object.entries(input).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ) + + if (entries.length === 1) { + return { outputName: entries[0]![0] } + } + + const serverEntry = entries.find(([name]) => name === 'server') + + if (serverEntry) { + return { outputName: serverEntry[0] } + } + + throw new Error('Unable to resolve Vite route-options entry point') + } + + return undefined +} + async function startPreviewServer( viteConfig: ResolvedConfig, ): Promise { diff --git a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts index 3130ca47c15..18b295543aa 100644 --- a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts @@ -2,6 +2,7 @@ import { VIRTUAL_MODULES } from '@tanstack/start-server-core' import { resolve as resolvePath } from 'pathe' import { SERVER_FN_LOOKUP, + START_ENVIRONMENT_NAMES, TRANSFORM_ID_REGEX, VITE_ENVIRONMENT_NAMES, } from '../../constants' @@ -200,7 +201,9 @@ export function startCompilerPlugin( // Environments that need the resolver: SSR (for server calls) and provider (for implementation) const appliedResolverEnvironments = new Set( - ssrIsProvider ? [opts.providerEnvName] : [ssrEnvName, opts.providerEnvName], + ssrIsProvider + ? [opts.providerEnvName, START_ENVIRONMENT_NAMES.prerender] + : [ssrEnvName, opts.providerEnvName, START_ENVIRONMENT_NAMES.prerender], ) function perEnvServerFnPlugin(environment: { @@ -208,7 +211,8 @@ export function startCompilerPlugin( type: 'client' | 'server' }): PluginOption { const compilerTransforms = - environment.name === opts.providerEnvName + environment.name === opts.providerEnvName || + environment.name === START_ENVIRONMENT_NAMES.prerender ? opts.compilerTransforms : undefined const serverFnProviderModuleDirectives = @@ -490,7 +494,10 @@ export function startCompilerPlugin( return appliedResolverEnvironments.has(env.name) }, load() { - if (this.environment.name !== opts.providerEnvName) { + if ( + this.environment.name !== opts.providerEnvName && + !(ssrIsProvider && this.environment.name === START_ENVIRONMENT_NAMES.prerender) + ) { const mod = opts.environments.find( (e) => e.name === this.environment.name, )?.getServerFnById diff --git a/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts index c9c86b3886d..539ac910df2 100644 --- a/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts @@ -13,7 +13,9 @@ import { pruneServerOnlySubtrees } from '../../start-router-plugin/pruneServerOn import { CLIENT_ROUTE_OPTION_DELETE_NODES, SERVER_PROP, + SERVER_ROUTE_OPTION_DELETE_NODES, } from '../../start-router-plugin/constants' +import { shouldUseSeparatePrerenderRouteOptions } from '../../prerender-route-options-env' import type { GetConfigFn } from '../../types' import type { TanStackStartVitePluginCoreOptions } from '../types' import type { @@ -181,6 +183,11 @@ export function tanStackStartRouter( ...routerConfig, codeSplittingOptions: { ...routerConfig.codeSplittingOptions, + deleteNodes: shouldUseSeparatePrerenderRouteOptions( + getConfig().startConfig, + ) + ? SERVER_ROUTE_OPTION_DELETE_NODES + : undefined, addHmr: false, }, plugin: { @@ -188,5 +195,18 @@ export function tanStackStartRouter( }, } }, routerPluginContext), + tanStackRouterCodeSplitter(() => { + const routerConfig = getConfig().startConfig.router + return { + ...routerConfig, + codeSplittingOptions: { + ...routerConfig.codeSplittingOptions, + addHmr: false, + }, + plugin: { + vite: { environmentName: VITE_ENVIRONMENT_NAMES.prerender }, + }, + } + }, routerPluginContext), ] } diff --git a/packages/start-plugin-core/tests/post-server-build.test.ts b/packages/start-plugin-core/tests/post-server-build.test.ts index df98cef4b27..b6479b611ee 100644 --- a/packages/start-plugin-core/tests/post-server-build.test.ts +++ b/packages/start-plugin-core/tests/post-server-build.test.ts @@ -11,31 +11,23 @@ vi.mock('../src/build-sitemap', () => ({ })) describe('postServerBuild', () => { - it('does not enable prerendering from dynamic route hints without prerender config', async () => { + it('does not enable prerendering when pages array is empty and prerender config is absent', async () => { const prerender = vi.fn(async () => {}) const { postBuild } = await import('../src/post-build') - globalThis.TSS_PRERENDER_DYNAMIC_ROUTES = [ - { routePath: '/posts/$postId', path: '/posts/$postId' }, - ] - - try { - await postBuild({ - startConfig: { - pages: [], - router: { basepath: '' }, - serverFns: { base: '' }, - spa: { enabled: false }, - sitemap: { enabled: false }, - } as any, - adapter: { - getClientOutputDirectory: () => '/client', - prerender, - }, - }) - } finally { - globalThis.TSS_PRERENDER_DYNAMIC_ROUTES = undefined - } + await postBuild({ + startConfig: { + pages: [], + router: { basepath: '' }, + serverFns: { base: '' }, + spa: { enabled: false }, + sitemap: { enabled: false }, + } as any, + adapter: { + getClientOutputDirectory: () => '/client', + prerender, + }, + }) expect(prerender).not.toHaveBeenCalled() }) @@ -70,7 +62,7 @@ describe('postServerBuild', () => { expect(prerender).not.toHaveBeenCalled() }) - it('limits SPA-only prerendering to the shell page', async () => { + it('keeps explicit prerender pages in SPA mode', async () => { const prerender = vi.fn(async () => {}) const { postBuild } = await import('../src/post-build') @@ -94,6 +86,47 @@ describe('postServerBuild', () => { }, }) + expect(prerender).toHaveBeenCalledWith( + expect.objectContaining({ + pages: [ + expect.objectContaining({ + path: '/about', + }), + expect.objectContaining({ + path: '/', + }), + ], + prerender: expect.objectContaining({ + enabled: true, + }), + }), + ) + }) + + it('limits SPA-only prerendering to the shell page', async () => { + const prerender = vi.fn(async () => {}) + const { postBuild } = await import('../src/post-build') + + const startConfig = { + spa: { + enabled: true, + maskPath: '/', + prerender: {}, + }, + pages: [{ path: '/about' }], + router: { basepath: '' }, + serverFns: { base: '' }, + sitemap: { enabled: false }, + } as any + + await postBuild({ + startConfig, + adapter: { + getClientOutputDirectory: () => '/client', + prerender, + }, + }) + expect(prerender).toHaveBeenCalledWith( expect.objectContaining({ pages: [ diff --git a/packages/start-plugin-core/tests/prerender-route-options-env.test.ts b/packages/start-plugin-core/tests/prerender-route-options-env.test.ts new file mode 100644 index 00000000000..8a27515b30f --- /dev/null +++ b/packages/start-plugin-core/tests/prerender-route-options-env.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { + applySeparatePrerenderRouteOptionsBundleDefault, + shouldUseSeparatePrerenderRouteOptions, +} from '../src/prerender-route-options-env' +import { parseStartConfig } from '../src/schema' + +describe('separate prerender route options environment', () => { + it('is enabled by default when prerendering is enabled', () => { + const startConfig = parseStartConfig( + { prerender: { enabled: true } }, + { framework: 'react' }, + process.cwd(), + ) + + expect(shouldUseSeparatePrerenderRouteOptions(startConfig)).toBe(true) + }) + + it('can be disabled to keep route options in the final server bundle', () => { + const startConfig = parseStartConfig( + { + prerender: { + enabled: true, + separateRouteOptionsBundle: false, + }, + }, + { framework: 'react' }, + process.cwd(), + ) + + expect(shouldUseSeparatePrerenderRouteOptions(startConfig)).toBe(false) + }) + + it('can be disabled by a deployment-specific default', () => { + const startConfig = parseStartConfig( + { prerender: { enabled: true } }, + { framework: 'react' }, + process.cwd(), + ) + + applySeparatePrerenderRouteOptionsBundleDefault(startConfig, false) + + expect(shouldUseSeparatePrerenderRouteOptions(startConfig)).toBe(false) + }) + + it('preserves explicit user configuration over deployment defaults', () => { + const startConfig = parseStartConfig( + { + prerender: { + enabled: true, + separateRouteOptionsBundle: true, + }, + }, + { framework: 'react' }, + process.cwd(), + ) + + applySeparatePrerenderRouteOptionsBundleDefault(startConfig, false) + + expect(shouldUseSeparatePrerenderRouteOptions(startConfig)).toBe(true) + }) + + it('stays disabled for SPA prerendering', () => { + const startConfig = parseStartConfig( + { + spa: { enabled: true }, + prerender: { enabled: true }, + }, + { framework: 'react' }, + process.cwd(), + ) + + expect(shouldUseSeparatePrerenderRouteOptions(startConfig)).toBe(false) + }) +}) diff --git a/packages/start-plugin-core/tests/prerender-routes-plugin.test.ts b/packages/start-plugin-core/tests/prerender-routes-plugin.test.ts index c48948865df..9523a772e58 100644 --- a/packages/start-plugin-core/tests/prerender-routes-plugin.test.ts +++ b/packages/start-plugin-core/tests/prerender-routes-plugin.test.ts @@ -4,10 +4,9 @@ import { prerenderRoutesPlugin } from '../src/start-router-plugin/generator-plug describe('prerenderRoutesPlugin', () => { afterEach(() => { globalThis.TSS_PRERENDABLE_PATHS = undefined - globalThis.TSS_PRERENDER_DYNAMIC_ROUTES = undefined }) - it('stores static and dynamic prerender routes on globalThis', () => { + it('stores static prerender routes on globalThis', () => { const plugin = prerenderRoutesPlugin() plugin.onRouteTreeChanged?.({ @@ -30,10 +29,6 @@ describe('prerenderRoutesPlugin', () => { } as any) expect(globalThis.TSS_PRERENDABLE_PATHS).toContainEqual({ path: '/about' }) - expect(globalThis.TSS_PRERENDER_DYNAMIC_ROUTES).toContainEqual({ - path: '/posts/$slug', - routePath: '/posts/$slug', - }) }) it('does not store API, layout, or dynamic routes as static paths', () => { @@ -68,44 +63,4 @@ describe('prerenderRoutesPlugin', () => { expect(globalThis.TSS_PRERENDABLE_PATHS).toEqual([{ path: '/' }]) }) - it('stores only prerenderParams routes as dynamic prerender hints', () => { - const plugin = prerenderRoutesPlugin() - - plugin.onRouteTreeChanged?.({ - routeTree: [], - rootRouteNode: { fullPath: '/src/routes/__root.tsx' } as any, - routeNodes: [ - { - routePath: '/posts/$slug', - path: '$slug', - fullPath: '/src/routes/posts.$slug.tsx', - createFileRouteProps: new Set(['component', 'prerenderParams']), - }, - { - routePath: '/posts/$slug', - path: '$slug', - fullPath: '/src/routes/posts.$slug.tsx', - createFileRouteProps: new Set(['component', 'prerenderParams']), - }, - { - routePath: '/products/$slug', - path: '$slug', - fullPath: '/src/routes/products.$slug.tsx', - createFileRouteProps: new Set(['component', 'sitemap']), - }, - { - path: '$slug', - fullPath: '/src/routes/missing-route-path.$slug.tsx', - createFileRouteProps: new Set(['component', 'prerenderParams']), - }, - ], - } as any) - - expect(globalThis.TSS_PRERENDER_DYNAMIC_ROUTES).toEqual([ - { - path: '/posts/$slug', - routePath: '/posts/$slug', - }, - ]) - }) }) diff --git a/packages/start-plugin-core/tests/prerender-ssrf.test.ts b/packages/start-plugin-core/tests/prerender-ssrf.test.ts index e9f9d349b79..d85191f91e1 100644 --- a/packages/start-plugin-core/tests/prerender-ssrf.test.ts +++ b/packages/start-plugin-core/tests/prerender-ssrf.test.ts @@ -73,6 +73,26 @@ describe('prerender pages validation', () => { expect(fetchMock).not.toHaveBeenCalled() }) + it('closes the handler and clears route tree state when prerendering fails', async () => { + resetFetch() + const startConfig = makeStartConfig('https://attacker.test/leak') + const close = vi.fn(async () => {}) + globalThis.TSS_PRERENDER_ROUTE_TREE = async () => undefined + + await expect( + prerender({ + startConfig, + handler: { + ...handler, + close, + }, + }), + ).rejects.toThrow(/prerender page path must be relative/i) + + expect(close).toHaveBeenCalledOnce() + expect(globalThis.TSS_PRERENDER_ROUTE_TREE).toBeUndefined() + }) + it('allows relative paths', async () => { resetFetch() const startConfig = makeStartConfig('/about') diff --git a/packages/start-plugin-core/tests/rsbuild-post-build.test.ts b/packages/start-plugin-core/tests/rsbuild-post-build.test.ts index 52311daa76e..7c07805f505 100644 --- a/packages/start-plugin-core/tests/rsbuild-post-build.test.ts +++ b/packages/start-plugin-core/tests/rsbuild-post-build.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it, vi } from 'vitest' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'pathe' @@ -10,6 +10,13 @@ vi.mock('@tanstack/start-server-core', () => ({ })) describe('postBuildWithRsbuild', () => { + beforeEach(() => { + vi.resetModules() + delete (globalThis as any).__ROUTE_OPTIONS_LOADED + delete process.env.TSS_PRERENDERING + delete process.env.TSS_CLIENT_OUTPUT_DIR + }) + it('imports server/index.js and accepts object fetch handlers', async () => { const serverOutputDirectory = await mkdtemp(join(tmpdir(), 'tss-rsbuild-')) const prerenderSpy = vi.fn(async ({ handler }: any) => { @@ -49,6 +56,7 @@ describe('postBuildWithRsbuild', () => { } as any, clientOutputDirectory: '/client', serverOutputDirectory, + separatePrerenderRouteOptions: false, }) expect(prerenderSpy).toHaveBeenCalledOnce() @@ -56,4 +64,112 @@ describe('postBuildWithRsbuild', () => { await rm(serverOutputDirectory, { recursive: true, force: true }) } }) + + it('imports route options from the prerender bundle and removes it', async () => { + const serverOutputDirectory = await mkdtemp(join(tmpdir(), 'tss-rsbuild-')) + const prerenderOutputDirectory = join(serverOutputDirectory, 'custom-prerender') + const prerenderSpy = vi.fn(async ({ handler }: any) => { + expect((globalThis as any).__ROUTE_OPTIONS_LOADED).toBe(true) + const response = await handler.request('/posts') + expect(await response.text()).toBe('ok') + await handler.close() + }) + + vi.doMock('../src/prerender', async () => { + const actual = await vi.importActual('../src/prerender') + return { + ...actual, + prerender: prerenderSpy, + } + }) + + await mkdir(prerenderOutputDirectory, { recursive: true }) + await writeFile( + join(serverOutputDirectory, 'index.js'), + [ + 'export default {', + ' fetch() {', + " return new Response(globalThis.__ROUTE_OPTIONS_LOADED ? 'ok' : 'missing')", + ' },', + '}', + ].join('\n'), + ) + await writeFile( + join(prerenderOutputDirectory, 'index.js'), + 'globalThis.__ROUTE_OPTIONS_LOADED = true', + ) + + const { postBuildWithRsbuild } = await import('../src/rsbuild/post-build') + + try { + await postBuildWithRsbuild({ + startConfig: { + prerender: { enabled: true, autoStaticPathsDiscovery: false }, + pages: [{ path: '/posts' }], + router: { basepath: '' }, + spa: { enabled: false, prerender: { outputPath: '/_shell' } }, + sitemap: { enabled: false }, + } as any, + clientOutputDirectory: '/client', + serverOutputDirectory, + prerenderOutputDirectory, + separatePrerenderRouteOptions: true, + }) + + expect(prerenderSpy).toHaveBeenCalledOnce() + expect(process.env.TSS_PRERENDERING).toBeUndefined() + expect(process.env.TSS_CLIENT_OUTPUT_DIR).toBeUndefined() + await expect(access(prerenderOutputDirectory)).rejects.toThrow() + } finally { + await rm(serverOutputDirectory, { recursive: true, force: true }) + } + }) + + it('cleans up route options and env vars if request handler preload fails', async () => { + const serverOutputDirectory = await mkdtemp(join(tmpdir(), 'tss-rsbuild-')) + const prerenderOutputDirectory = join(serverOutputDirectory, 'custom-prerender') + const prerenderSpy = vi.fn() + + vi.doMock('../src/prerender', async () => { + const actual = await vi.importActual('../src/prerender') + return { + ...actual, + prerender: prerenderSpy, + } + }) + + await mkdir(prerenderOutputDirectory, { recursive: true }) + await writeFile( + join(prerenderOutputDirectory, 'index.js'), + 'globalThis.__ROUTE_OPTIONS_LOADED = true', + ) + + const { postBuildWithRsbuild } = await import('../src/rsbuild/post-build') + + try { + await expect( + postBuildWithRsbuild({ + startConfig: { + prerender: { enabled: true, autoStaticPathsDiscovery: false }, + pages: [{ path: '/posts' }], + router: { basepath: '' }, + spa: { enabled: false, prerender: { outputPath: '/_shell' } }, + sitemap: { enabled: false }, + } as any, + clientOutputDirectory: '/client', + serverOutputDirectory, + prerenderOutputDirectory, + separatePrerenderRouteOptions: true, + }), + ).rejects.toThrow() + + expect(prerenderSpy).not.toHaveBeenCalled() + expect(process.env.TSS_PRERENDERING).toBeUndefined() + expect(process.env.TSS_CLIENT_OUTPUT_DIR).toBeUndefined() + expect(globalThis.TSS_PRERENDER_ROUTE_TREE).toBeUndefined() + await expect(access(prerenderOutputDirectory)).rejects.toThrow() + } finally { + await rm(serverOutputDirectory, { recursive: true, force: true }) + } + }) }) diff --git a/packages/start-plugin-core/tests/start-router-plugin-constants.test.ts b/packages/start-plugin-core/tests/start-router-plugin-constants.test.ts index 88678e8b528..5a8f73fb07c 100644 --- a/packages/start-plugin-core/tests/start-router-plugin-constants.test.ts +++ b/packages/start-plugin-core/tests/start-router-plugin-constants.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { CLIENT_ROUTE_OPTION_DELETE_NODES } from '../src/start-router-plugin/constants' +import { + CLIENT_ROUTE_OPTION_DELETE_NODES, + SERVER_ROUTE_OPTION_DELETE_NODES, +} from '../src/start-router-plugin/constants' describe('client route option stripping', () => { it('strips server-only and prerender route options from client bundles', () => { @@ -11,4 +14,11 @@ describe('client route option stripping', () => { 'sitemap', ]) }) + + it('strips prerender route options from separate final server bundles', () => { + expect(SERVER_ROUTE_OPTION_DELETE_NODES).toEqual([ + 'prerenderParams', + 'sitemap', + ]) + }) }) diff --git a/packages/start-plugin-core/tests/vite-nitro.test.ts b/packages/start-plugin-core/tests/vite-nitro.test.ts new file mode 100644 index 00000000000..f9f03bc4fbd --- /dev/null +++ b/packages/start-plugin-core/tests/vite-nitro.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { hasNitroPlugin } from '../src/vite/nitro' + +describe('hasNitroPlugin', () => { + it('detects Nitro Vite plugin entries', () => { + expect(hasNitroPlugin([{ name: 'nitro:env' }])).toBe(true) + }) + + it('detects nested Nitro Vite plugin entries', () => { + expect(hasNitroPlugin([[{ name: 'nitro:env' }]])).toBe(true) + }) + + it('ignores non-Nitro plugins', () => { + expect(hasNitroPlugin([{ name: 'vite:react' }])).toBe(false) + }) +}) diff --git a/packages/start-plugin-core/tests/vite-planning.test.ts b/packages/start-plugin-core/tests/vite-planning.test.ts new file mode 100644 index 00000000000..daf2ae17614 --- /dev/null +++ b/packages/start-plugin-core/tests/vite-planning.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { + createViteConfigPlan, + createViteResolvedEntryAliases, +} from '../src/vite/planning' + +describe('Vite planning', () => { + it('uses a non-Nitro-service input for the prerender route options environment', () => { + const entryAliases = createViteResolvedEntryAliases({ + entryPaths: { + client: '/app/src/client.tsx', + server: '/app/src/server.tsx', + start: '/app/src/start.tsx', + router: '/app/src/router.tsx', + }, + }) + + const plan = createViteConfigPlan({ + viteConfig: {}, + framework: 'react', + entryAliases, + clientOutputDirectory: '/app/dist/client', + serverOutputDirectory: '/app/dist/server', + serverFnProviderEnv: 'ssr', + separatePrerenderRouteOptions: true, + optimizeDepsExclude: [], + noExternal: [], + }) + + const prerenderEnvironment = plan.environments.prerender + + expect(prerenderEnvironment).toBeDefined() + expect(prerenderEnvironment!.build.rollupOptions.input).toEqual({ + server: '/app/src/server.tsx', + }) + }) +}) diff --git a/packages/start-plugin-core/tests/vite-prerender.test.ts b/packages/start-plugin-core/tests/vite-prerender.test.ts new file mode 100644 index 00000000000..4ec24c42c62 --- /dev/null +++ b/packages/start-plugin-core/tests/vite-prerender.test.ts @@ -0,0 +1,162 @@ +import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('prerenderWithVite', () => { + beforeEach(() => { + vi.resetModules() + delete process.env.TSS_PRERENDERING + delete process.env.TSS_CLIENT_OUTPUT_DIR + delete (globalThis as any).__ROUTE_OPTIONS_LOADED + delete globalThis.TSS_PRERENDER_ROUTE_TREE + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('imports route options from the prerender bundle and cleans up', async () => { + const root = await mkdtemp(join(tmpdir(), 'tss-vite-prerender-')) + const clientOutputDirectory = join(root, 'client') + const serverOutputDirectory = join(root, 'server') + const prerenderOutputDirectory = join(serverOutputDirectory, '.tanstack/prerender') + const close = vi.fn() + const prerenderSpy = vi.fn(async ({ handler }: any) => { + expect((globalThis as any).__ROUTE_OPTIONS_LOADED).toBe(1) + await handler.close() + }) + + vi.doMock('vite', () => ({ + preview: vi.fn(async () => ({ + resolvedUrls: { local: ['http://127.0.0.1:4173/'] }, + close, + })), + })) + vi.doMock('../src/prerender', async () => { + const actual = await vi.importActual('../src/prerender') + return { + ...actual, + prerender: prerenderSpy, + } + }) + + await mkdir(prerenderOutputDirectory, { recursive: true }) + await writeFile( + join(prerenderOutputDirectory, 'server.js'), + 'globalThis.__ROUTE_OPTIONS_LOADED = (globalThis.__ROUTE_OPTIONS_LOADED ?? 0) + 1', + ) + + const { prerenderWithVite } = await import('../src/vite/prerender') + + try { + await prerenderWithVite({ + startConfig: createStartConfig(true), + builder: createBuilder({ + clientOutputDirectory, + serverOutputDirectory, + prerenderOutputDirectory, + }), + } as any) + + expect(prerenderSpy).toHaveBeenCalledOnce() + expect(close).toHaveBeenCalledOnce() + expect(process.env.TSS_PRERENDERING).toBeUndefined() + expect(process.env.TSS_CLIENT_OUTPUT_DIR).toBeUndefined() + expect(globalThis.TSS_PRERENDER_ROUTE_TREE).toBeUndefined() + await expect(access(prerenderOutputDirectory)).rejects.toThrow() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('cleans up if route-options import fails', async () => { + const root = await mkdtemp(join(tmpdir(), 'tss-vite-prerender-')) + const clientOutputDirectory = join(root, 'client') + const serverOutputDirectory = join(root, 'server') + const prerenderOutputDirectory = join(serverOutputDirectory, '.tanstack/prerender') + const prerenderSpy = vi.fn() + + vi.doMock('vite', () => ({ + preview: vi.fn(), + })) + vi.doMock('../src/prerender', async () => { + const actual = await vi.importActual('../src/prerender') + return { + ...actual, + prerender: prerenderSpy, + } + }) + + await mkdir(prerenderOutputDirectory, { recursive: true }) + await writeFile(join(prerenderOutputDirectory, 'server.js'), 'throw new Error("boom")') + + const { prerenderWithVite } = await import('../src/vite/prerender') + + try { + await expect( + prerenderWithVite({ + startConfig: createStartConfig(true), + builder: createBuilder({ + clientOutputDirectory, + serverOutputDirectory, + prerenderOutputDirectory, + }), + } as any), + ).rejects.toThrow('boom') + + expect(prerenderSpy).not.toHaveBeenCalled() + expect(process.env.TSS_PRERENDERING).toBeUndefined() + expect(process.env.TSS_CLIENT_OUTPUT_DIR).toBeUndefined() + expect(globalThis.TSS_PRERENDER_ROUTE_TREE).toBeUndefined() + await expect(access(prerenderOutputDirectory)).rejects.toThrow() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) + +function createStartConfig(separateRouteOptionsBundle: boolean) { + return { + prerender: { enabled: true, separateRouteOptionsBundle }, + pages: [], + router: { basepath: '' }, + spa: { enabled: false, prerender: { outputPath: '/_shell' } }, + sitemap: { enabled: false }, + } +} + +function createBuilder({ + clientOutputDirectory, + serverOutputDirectory, + prerenderOutputDirectory, +}: { + clientOutputDirectory: string + serverOutputDirectory: string + prerenderOutputDirectory: string +}) { + return { + environments: { + client: { + config: { build: { outDir: clientOutputDirectory } }, + }, + ssr: { + config: { + configFile: false, + build: { + outDir: serverOutputDirectory, + rollupOptions: { input: { server: 'src/server.ts' } }, + }, + }, + }, + prerender: { + config: { + build: { + outDir: prerenderOutputDirectory, + rollupOptions: { input: { server: 'src/server.ts' } }, + }, + }, + }, + }, + } +} diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 24814d801fb..e904a37492f 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -373,7 +373,7 @@ function getEntries() { if (process.env.TSS_PRERENDERING === 'true') { // The prerenderer imports the server entry before crawling so it can read // server-only route options like prerenderParams from the initialized router. - globalThis.TSS_PRERENDER_ROUTE_TREE = async () => { + globalThis.TSS_PRERENDER_ROUTE_TREE ??= async () => { const entries = await getEntries() const router = await entries.routerEntry.getRouter() return router.routeTree From 8cd8ffe5d6d5b9a7fd754bcde24a51c55c257d34 Mon Sep 17 00:00:00 2001 From: jon Date: Tue, 5 May 2026 22:26:27 +0100 Subject: [PATCH 06/11] fix: harden prerender route options bundling --- .../react/guide/static-prerendering.md | 5 + .../solid/guide/static-prerendering.md | 5 + e2e/solid-start/basic/rsbuild.config.ts | 6 + e2e/vue-start/basic/rsbuild.config.ts | 6 + packages/start-plugin-core/src/post-build.ts | 5 +- .../src/prerender-params-runner.ts | 87 +++++++++------ .../src/prerender-route-options-env.ts | 6 +- packages/start-plugin-core/src/prerender.ts | 6 +- .../start-plugin-core/src/rsbuild/plugin.ts | 34 +++--- .../src/rsbuild/post-build.ts | 32 +++--- .../src/rsbuild/start-router-plugin.ts | 14 +-- .../src/rsbuild/virtual-modules.ts | 4 +- .../start-plugin-core/src/vite/planning.ts | 16 ++- packages/start-plugin-core/src/vite/plugin.ts | 13 +-- .../start-plugin-core/src/vite/prerender.ts | 32 +++--- .../src/vite/start-router-plugin/plugin.ts | 10 +- .../tests/prerender-params-runner.test.ts | 36 ++++++ .../tests/prerender-route-options-env.test.ts | 20 ++-- .../tests/prerender-ssrf.test.ts | 11 ++ .../tests/vite-prerender.test.ts | 104 ++++++++++++++++++ 20 files changed, 325 insertions(+), 127 deletions(-) diff --git a/docs/start/framework/react/guide/static-prerendering.md b/docs/start/framework/react/guide/static-prerendering.md index 628291a9230..cbca449c058 100644 --- a/docs/start/framework/react/guide/static-prerendering.md +++ b/docs/start/framework/react/guide/static-prerendering.md @@ -49,6 +49,9 @@ export default defineConfig({ // Maximum time in milliseconds to wait for each prerenderParams callback prerenderParamsTimeout: 30000, + // Build route options used for prerendering separately from the server bundle + separateRouteOptionsBundle: true, + // Fail if an error occurs during prerendering failOnError: true, @@ -72,6 +75,8 @@ export default defineConfig({ }) ``` +By default, Start builds route options used by `prerenderParams` and `sitemap` separately from the final server bundle so they can be used at build time without being deployed. Some deployment adapters may override this default for compatibility. For example, Nitro keeps route options in the server bundle unless you explicitly set `prerender.separateRouteOptionsBundle` to `true`. Set `prerender.separateRouteOptionsBundle` to `false` if your deployment adapter does not support the extra build environment or if you prefer to keep those route options in the server bundle. + ## Automatic Static Route Discovery All static paths will be automatically discovered and seamlessly merged with the specified `pages` config diff --git a/docs/start/framework/solid/guide/static-prerendering.md b/docs/start/framework/solid/guide/static-prerendering.md index 07f0daf58fa..df9d78cef0d 100644 --- a/docs/start/framework/solid/guide/static-prerendering.md +++ b/docs/start/framework/solid/guide/static-prerendering.md @@ -49,6 +49,9 @@ export default defineConfig({ // Maximum time in milliseconds to wait for each prerenderParams callback prerenderParamsTimeout: 30000, + // Build route options used for prerendering separately from the server bundle + separateRouteOptionsBundle: true, + // Fail if an error occurs during prerendering failOnError: true, @@ -72,6 +75,8 @@ export default defineConfig({ }) ``` +By default, Start builds route options used by `prerenderParams` and `sitemap` separately from the final server bundle so they can be used at build time without being deployed. Some deployment adapters may override this default for compatibility. For example, Nitro keeps route options in the server bundle unless you explicitly set `prerender.separateRouteOptionsBundle` to `true`. Set `prerender.separateRouteOptionsBundle` to `false` if your deployment adapter does not support the extra build environment or if you prefer to keep those route options in the server bundle. + ## Automatic Static Route Discovery All static paths will be automatically discovered and seamlessly merged with the specified `pages` config diff --git a/e2e/solid-start/basic/rsbuild.config.ts b/e2e/solid-start/basic/rsbuild.config.ts index 5067f95ac5a..f3ed85b01d7 100644 --- a/e2e/solid-start/basic/rsbuild.config.ts +++ b/e2e/solid-start/basic/rsbuild.config.ts @@ -32,6 +32,12 @@ export default defineConfig({ pluginSolid(), tanstackStart({ prerender: isPrerender ? prerenderConfiguration : undefined, + sitemap: isPrerender + ? { + enabled: true, + host: 'https://example.com', + } + : undefined, }), ], output: { diff --git a/e2e/vue-start/basic/rsbuild.config.ts b/e2e/vue-start/basic/rsbuild.config.ts index fc8ff3b53ff..c16648bc419 100644 --- a/e2e/vue-start/basic/rsbuild.config.ts +++ b/e2e/vue-start/basic/rsbuild.config.ts @@ -34,6 +34,12 @@ export default defineConfig({ pluginVueJsx(), tanstackStart({ prerender: isPrerender ? prerenderConfiguration : undefined, + sitemap: isPrerender + ? { + enabled: true, + host: 'https://example.com', + } + : undefined, }), ], performance: { diff --git a/packages/start-plugin-core/src/post-build.ts b/packages/start-plugin-core/src/post-build.ts index f4f1e9942ff..ddb2f7f9c81 100644 --- a/packages/start-plugin-core/src/post-build.ts +++ b/packages/start-plugin-core/src/post-build.ts @@ -23,8 +23,9 @@ export async function postBuild({ } } - const spaOnly = - startConfig.spa?.enabled && startConfig.prerender.enabled !== true + const spaOnly = Boolean( + startConfig.spa?.enabled && startConfig.prerender.enabled !== true, + ) if (startConfig.spa?.enabled) { if (spaOnly) { diff --git a/packages/start-plugin-core/src/prerender-params-runner.ts b/packages/start-plugin-core/src/prerender-params-runner.ts index d78d7930aea..959d27ca0af 100644 --- a/packages/start-plugin-core/src/prerender-params-runner.ts +++ b/packages/start-plugin-core/src/prerender-params-runner.ts @@ -15,6 +15,13 @@ interface PrerenderParamsLogger { warn: (...args: Array) => void } +interface PrerenderParamsEntry { + params: Record + search?: Record + sitemap?: RouteSitemapOptions + prerender?: RoutePrerenderOptions +} + export interface RunPrerenderParamsOptions { routeTree: AnyRoute | undefined pages: Array @@ -39,33 +46,33 @@ export async function runPrerenderParams({ if (!options?.sitemap) continue const page = pagesByPath.get(route.path) - if (!page || dynamic(route.path)) continue + if (!page || isDynamicPath(route.path)) continue pagesByPath.set(route.path, merge(page, { sitemap: options.sitemap })) } const controller = new AbortController() - const cleanupProcessAbort = signals(controller) + const cleanupProcessAbort = attachProcessAbortHandlers(controller) try { for (const route of dynamicRoutes) { const options = routeOptions.get(route.routePath) if (!options?.prerenderParams) continue - if (!dynamic(route.path)) { + if (!isDynamicPath(route.path)) { logger.warn( `Skipping prerenderParams for static route ${route.routePath}; static routes are already discovered automatically.`, ) continue } - const cleanupTimeout = timeout( + const cleanupTimeout = startPrerenderParamsTimeout( controller, prerenderParamsTimeout, route.routePath, ) - const entries = await call( + const entries = await runWithAbortSignal( () => options.prerenderParams!({ routePath: route.routePath, @@ -74,8 +81,14 @@ export async function runPrerenderParams({ controller.signal, ).finally(cleanupTimeout) + if (!Array.isArray(entries)) { + throw new Error( + `prerenderParams for route ${route.routePath} must return an array`, + ) + } + for (const entry of entries) { - const page = create(route, options, entry) + const page = createPageFromParams(route, options, entry) if (filter && !filter(page)) { continue @@ -94,7 +107,7 @@ export async function runPrerenderParams({ return Array.from(pagesByPath.values()) } -function signals(controller: AbortController) { +function attachProcessAbortHandlers(controller: AbortController) { const abort = () => controller.abort() process.once('SIGINT', abort) @@ -106,7 +119,7 @@ function signals(controller: AbortController) { } } -function timeout( +function startPrerenderParamsTimeout( controller: AbortController, timeout: number | undefined, routePath: string, @@ -115,6 +128,10 @@ function timeout( return () => {} } + if (!Number.isFinite(timeout) || timeout < 0) { + throw new Error('prerenderParamsTimeout must be a non-negative finite number') + } + const timeoutId = setTimeout(() => { controller.abort( new Error(`prerenderParams for route ${routePath} timed out`), @@ -124,7 +141,7 @@ function timeout( return () => clearTimeout(timeoutId) } -async function call( +async function runWithAbortSignal( callback: () => T | Promise, signal: AbortSignal, ): Promise { @@ -152,16 +169,17 @@ async function call( }) } -function create( +function createPageFromParams( route: PrerenderRouteMetadata, options: PrerenderRouteOptions, - entry: { - params: Record - search?: Record - sitemap?: RouteSitemapOptions - prerender?: RoutePrerenderOptions - }, + entry: unknown, ): Page { + if (!isPrerenderParamsEntry(entry)) { + throw new Error( + `prerenderParams entry for route ${route.routePath} must include params`, + ) + } + const { interpolatedPath, isMissingParams, usedParams } = interpolatePath({ path: route.path, params: entry.params, @@ -179,43 +197,44 @@ function create( } return { - path: interpolatedPath + search(entry.search), - sitemap: sitemap(options.sitemap, entry.sitemap), + path: interpolatedPath + stringifySearch(entry.search), + sitemap: mergeOptions(options.sitemap, entry.sitemap), prerender: entry.prerender, } } -function search(value: Record | undefined) { +function stringifySearch(value: Record | undefined) { return value ? defaultStringifySearch(value) : '' } +function isPrerenderParamsEntry(value: unknown): value is PrerenderParamsEntry { + return ( + !!value && + typeof value === 'object' && + 'params' in value && + !!value.params && + typeof value.params === 'object' + ) +} + function merge(base: Page, override: Partial): Page { return { ...base, ...override, - sitemap: sitemap(base.sitemap, override.sitemap), - prerender: prerender(base.prerender, override.prerender), + sitemap: mergeOptions(base.sitemap, override.sitemap), + prerender: mergeOptions(base.prerender, override.prerender), } } -function sitemap( - base: RouteSitemapOptions | undefined, - override: RouteSitemapOptions | undefined, -) { - if (!base) return override - if (!override) return base - return { ...base, ...override } -} - -function prerender( - base: RoutePrerenderOptions | undefined, - override: RoutePrerenderOptions | undefined, +function mergeOptions( + base: T | undefined, + override: T | undefined, ) { if (!base) return override if (!override) return base return { ...base, ...override } } -function dynamic(path: string) { +function isDynamicPath(path: string) { return path.includes('$') } diff --git a/packages/start-plugin-core/src/prerender-route-options-env.ts b/packages/start-plugin-core/src/prerender-route-options-env.ts index b07f739eab3..9090644e070 100644 --- a/packages/start-plugin-core/src/prerender-route-options-env.ts +++ b/packages/start-plugin-core/src/prerender-route-options-env.ts @@ -26,7 +26,7 @@ export function restorePrerenderEnv(state: PrerenderEnvState) { } } -export function applySeparatePrerenderRouteOptionsBundleDefault( +export function applySeparateRouteOptionsDefault( startConfig: TanStackStartOutputConfig, defaultValue: boolean, ) { @@ -40,7 +40,7 @@ export function applySeparatePrerenderRouteOptionsBundleDefault( } } -export function shouldUseSeparatePrerenderRouteOptions( +export function shouldSeparateRouteOptions( startConfig: TanStackStartOutputConfig, ) { if (startConfig.prerender?.separateRouteOptionsBundle === false) { @@ -51,5 +51,5 @@ export function shouldUseSeparatePrerenderRouteOptions( startConfig.prerender?.enabled ?? startConfig.pages.some((page) => page.prerender?.enabled) - return prerenderEnabled && !startConfig.spa?.enabled + return prerenderEnabled || Boolean(startConfig.spa?.enabled) } diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts index dd70314cc57..0ee6e52b4ae 100644 --- a/packages/start-plugin-core/src/prerender.ts +++ b/packages/start-plugin-core/src/prerender.ts @@ -43,7 +43,11 @@ export async function prerender({ } if (!startConfig.spa?.enabled) { - const routeTree = await globalThis.TSS_PRERENDER_ROUTE_TREE?.() + if (!globalThis.TSS_PRERENDER_ROUTE_TREE) { + throw new Error('Prerender route options were not loaded') + } + + const routeTree = await globalThis.TSS_PRERENDER_ROUTE_TREE() pages = await runPrerenderParams({ routeTree, diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts index 7e261e6ca52..26957ac5cc3 100644 --- a/packages/start-plugin-core/src/rsbuild/plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/plugin.ts @@ -10,7 +10,7 @@ import { } from '../config-context' import { normalizePath } from '../utils' import { createServerFnBasePath, normalizePublicBase } from '../planning' -import { shouldUseSeparatePrerenderRouteOptions } from '../prerender-route-options-env' +import { shouldSeparateRouteOptions } from '../prerender-route-options-env' import { parseStartConfig } from './schema' import { RSBUILD_ENVIRONMENT_NAMES, @@ -148,6 +148,7 @@ export function tanStackStartRsbuild( const entryAliases = createRsbuildResolvedEntryAliases({ entryPaths: resolvedEntryPlan.entryPaths, }) + const separateRouteOptions = shouldSeparateRouteOptions(startConfig) const environmentPlan = createRsbuildEnvironmentPlan({ root, @@ -156,23 +157,24 @@ export function tanStackStartRsbuild( serverOutputDirectory: resolvedStartConfig.outputDirectories.server, publicBase: resolvedStartConfig.basePaths.publicBase, serverFnProviderEnv, - separatePrerenderRouteOptions: - shouldUseSeparatePrerenderRouteOptions(startConfig), + separatePrerenderRouteOptions: separateRouteOptions, environmentOverrides: corePluginOpts.rsbuild?.environments, rsc: rscOpts, dev: isDev, }) - prerenderOutputDirectory = resolveRsbuildOutputDirectory({ - distPath: - environmentPlan.environments[RSBUILD_ENVIRONMENT_NAMES.prerender] - ?.output?.distPath, - rootDistPath: undefined, - fallback: join( - resolvedStartConfig.outputDirectories.server, - '.tanstack/prerender', - ), - subdirectory: 'prerender', - }) + prerenderOutputDirectory = separateRouteOptions + ? resolveRsbuildOutputDirectory({ + distPath: + environmentPlan.environments[RSBUILD_ENVIRONMENT_NAMES.prerender] + ?.output?.distPath, + rootDistPath: undefined, + fallback: join( + resolvedStartConfig.outputDirectories.server, + '.tanstack/prerender', + ), + subdirectory: 'prerender', + }) + : undefined const serverFnBase = createServerFnBasePath({ routerBasepath, serverFnBase: startConfig.serverFns.base, @@ -691,14 +693,14 @@ export function tanStackStartRsbuild( if (api.context.action === 'build') { api.onAfterBuild(async () => { const { startConfig } = getConfig() + const separateRouteOptions = shouldSeparateRouteOptions(startConfig) await postBuildWithRsbuild({ startConfig, clientOutputDirectory: resolvedStartConfig.outputDirectories.client, serverOutputDirectory: resolvedStartConfig.outputDirectories.server, prerenderOutputDirectory, - separatePrerenderRouteOptions: - shouldUseSeparatePrerenderRouteOptions(startConfig), + separatePrerenderRouteOptions: separateRouteOptions, }) }) } diff --git a/packages/start-plugin-core/src/rsbuild/post-build.ts b/packages/start-plugin-core/src/rsbuild/post-build.ts index 4c1ae0ec621..0128190aa2c 100644 --- a/packages/start-plugin-core/src/rsbuild/post-build.ts +++ b/packages/start-plugin-core/src/rsbuild/post-build.ts @@ -29,19 +29,12 @@ export async function postBuildWithRsbuild({ return clientOutputDirectory }, async prerender(startConfig) { - const handler = createRsbuildPrerenderHandler({ + const handler = await createRsbuildPrerenderHandler({ clientOutputDirectory, serverOutputDirectory, prerenderOutputDirectory, separatePrerenderRouteOptions, }) - try { - await handler.loadRouteOptions() - await handler.loadRequestHandler() - } catch (error) { - await handler.close?.() - throw error - } return prerender({ startConfig, @@ -52,7 +45,7 @@ export async function postBuildWithRsbuild({ }) } -function createRsbuildPrerenderHandler({ +async function createRsbuildPrerenderHandler({ clientOutputDirectory, serverOutputDirectory, prerenderOutputDirectory, @@ -62,12 +55,7 @@ function createRsbuildPrerenderHandler({ serverOutputDirectory: string prerenderOutputDirectory?: string | undefined separatePrerenderRouteOptions: boolean -}): PrerenderHandler & { - loadRouteOptions: () => Promise - loadRequestHandler: () => Promise< - (request: Request, opts?: unknown) => Promise | Response - > -} { +}): Promise { const prerenderEnvState = capturePrerenderEnv() process.env.TSS_PRERENDERING = 'true' @@ -81,9 +69,7 @@ function createRsbuildPrerenderHandler({ let routeOptionsPromise: Promise | undefined - return { - loadRouteOptions, - loadRequestHandler, + const handler: PrerenderHandler = { getClientOutputDirectory() { return clientOutputDirectory }, @@ -110,6 +96,16 @@ function createRsbuildPrerenderHandler({ }, } + try { + await loadRouteOptions() + await loadRequestHandler() + } catch (error) { + await handler.close?.() + throw error + } + + return handler + function loadRequestHandler() { if (!requestHandlerPromise) { requestHandlerPromise = loadRequestHandlerFromBundle( diff --git a/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts b/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts index a9dc02e9a78..2e81a3b0102 100644 --- a/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts @@ -11,7 +11,7 @@ import { CLIENT_ROUTE_OPTION_DELETE_NODES, SERVER_ROUTE_OPTION_DELETE_NODES, } from '../start-router-plugin/constants' -import { shouldUseSeparatePrerenderRouteOptions } from '../prerender-route-options-env' +import { shouldSeparateRouteOptions } from '../prerender-route-options-env' import { RSBUILD_ENVIRONMENT_NAMES } from './planning' import type { RsbuildPluginAPI } from '@rsbuild/core' import type { GetConfigFn, TanStackStartCoreOptions } from '../types' @@ -74,18 +74,18 @@ export function registerRouterPlugins( ) { const isClient = envName === RSBUILD_ENVIRONMENT_NAMES.client const isServer = envName === RSBUILD_ENVIRONMENT_NAMES.server + const deleteNodes = isClient + ? CLIENT_ROUTE_OPTION_DELETE_NODES + : isServer && shouldSeparateRouteOptions(startConfig) + ? SERVER_ROUTE_OPTION_DELETE_NODES + : undefined const splitterPlugin = TanStackRouterCodeSplitterRspack( { ...routerConfig, target: opts.corePluginOpts.framework, codeSplittingOptions: { ...routerConfig.codeSplittingOptions, - deleteNodes: isClient - ? CLIENT_ROUTE_OPTION_DELETE_NODES - : isServer && - shouldUseSeparatePrerenderRouteOptions(startConfig) - ? SERVER_ROUTE_OPTION_DELETE_NODES - : undefined, + deleteNodes, addHmr: isClient, }, }, diff --git a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts index fadd5e2d40d..2ec6839b984 100644 --- a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts +++ b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts @@ -549,7 +549,9 @@ export function createFromReadableStream() { throw new Error('RSC SSR decode is updateServerFnResolver() { for (const environmentName of new Set([ RSBUILD_ENVIRONMENT_NAMES.server, - RSBUILD_ENVIRONMENT_NAMES.prerender, + ...(vmPlugins[RSBUILD_ENVIRONMENT_NAMES.prerender] + ? [RSBUILD_ENVIRONMENT_NAMES.prerender] + : []), ...(hasSeparateProviderEnvironment ? [opts.providerEnvName] : []), ])) { if (!needsServerFnResolver(environmentName)) { diff --git a/packages/start-plugin-core/src/vite/planning.ts b/packages/start-plugin-core/src/vite/planning.ts index 06ac6eae54f..c0450edd2b2 100644 --- a/packages/start-plugin-core/src/vite/planning.ts +++ b/packages/start-plugin-core/src/vite/planning.ts @@ -52,8 +52,6 @@ export function createViteConfigPlan(opts: { getBundlerOptions( opts.viteConfig.environments?.[START_ENVIRONMENT_NAMES.server]?.build, )?.input ?? opts.entryAliases.server - const prerenderInput = - typeof serverInput === 'string' ? { server: serverInput } : serverInput return { environments: { @@ -106,7 +104,12 @@ export function createViteConfigPlan(opts: { consumer: 'server', build: { ssr: true, - ...buildViteInputOptions(prerenderInput), + ...buildViteInputOptions( + typeof serverInput === 'string' + ? { server: serverInput } + : serverInput, + [/^cloudflare:/], + ), outDir: join( opts.serverOutputDirectory, '.tanstack/prerender', @@ -249,8 +252,11 @@ function escapeEntries(entries: Array) { return entries.map((entry) => escapePath(entry)) } -function buildViteInputOptions(input: NonNullable['input']) { - const bundlerOptions = { input } +function buildViteInputOptions( + input: NonNullable['input'], + external?: NonNullable['external'], +) { + const bundlerOptions = external ? { input, external } : { input } return { rollupOptions: bundlerOptions, diff --git a/packages/start-plugin-core/src/vite/plugin.ts b/packages/start-plugin-core/src/vite/plugin.ts index ef9656fb9e4..c653b3c911b 100644 --- a/packages/start-plugin-core/src/vite/plugin.ts +++ b/packages/start-plugin-core/src/vite/plugin.ts @@ -11,8 +11,8 @@ import { shouldRewriteDevBasepath, } from '../planning' import { - applySeparatePrerenderRouteOptionsBundleDefault, - shouldUseSeparatePrerenderRouteOptions, + applySeparateRouteOptionsDefault, + shouldSeparateRouteOptions, } from '../prerender-route-options-env' import { importProtectionPlugin } from './import-protection-plugin/plugin' import { startCompilerPlugin } from './start-compiler-plugin/plugin' @@ -111,10 +111,11 @@ export function tanStackStartVite( serverOutputDirectory: getServerOutputDirectory(viteConfig), }) const { startConfig } = getConfig() - applySeparatePrerenderRouteOptionsBundleDefault( + applySeparateRouteOptionsDefault( startConfig, !hasNitroPlugin(viteConfig.plugins), ) + const separateRouteOptions = shouldSeparateRouteOptions(startConfig) const routerBasepath = applyResolvedRouterBasepath({ resolvedStartConfig, startConfig, @@ -181,8 +182,7 @@ export function tanStackStartVite( clientOutputDirectory: resolvedStartConfig.outputDirectories.client, serverOutputDirectory: resolvedStartConfig.outputDirectories.server, serverFnProviderEnv, - separatePrerenderRouteOptions: - shouldUseSeparatePrerenderRouteOptions(startConfig), + separatePrerenderRouteOptions: separateRouteOptions, optimizeDepsExclude: crawlFrameworkPkgsResult.optimizeDeps.exclude, noExternal: crawlFrameworkPkgsResult.ssr.noExternal.sort(), }) @@ -214,8 +214,7 @@ export function tanStackStartVite( builder, providerEnvironmentName: serverFnProviderEnv, ssrIsProvider, - separatePrerenderRouteOptions: - shouldUseSeparatePrerenderRouteOptions(startConfig), + separatePrerenderRouteOptions: separateRouteOptions, }) }, }, diff --git a/packages/start-plugin-core/src/vite/prerender.ts b/packages/start-plugin-core/src/vite/prerender.ts index f40db29ea18..f039057e12e 100644 --- a/packages/start-plugin-core/src/vite/prerender.ts +++ b/packages/start-plugin-core/src/vite/prerender.ts @@ -6,7 +6,7 @@ import { prerender } from '../prerender' import { capturePrerenderEnv, restorePrerenderEnv, - shouldUseSeparatePrerenderRouteOptions, + shouldSeparateRouteOptions, } from '../prerender-route-options-env' import { getBundlerOptions } from '../utils' import { getServerOutputDirectory } from './output-directory' @@ -99,7 +99,7 @@ async function importRouteOptionsEntry({ serverEnv: NonNullable prerenderEnv: ViteBuilder['environments'][string] | undefined }): Promise { - const separateRouteOptions = shouldUseSeparatePrerenderRouteOptions(startConfig) + const separateRouteOptions = shouldSeparateRouteOptions(startConfig) const routeOptionsEnv = separateRouteOptions ? prerenderEnv : serverEnv if (!routeOptionsEnv) { @@ -108,11 +108,11 @@ async function importRouteOptionsEntry({ ) } - const entry = getRouteOptionsEntry( + const outputName = getRouteOptionsEntryName( getBundlerOptions(routeOptionsEnv.config.build)?.input ?? 'server', ) - if (!entry) { + if (!outputName) { return undefined } @@ -123,7 +123,7 @@ async function importRouteOptionsEntry({ const outputDir = separateRouteOptions ? routeOptionsEnv.config.build.outDir : getServerOutputDirectory(serverEnv.config) - const entryPath = await resolveRouteOptionsEntryPath(outputDir, entry.outputName) + const entryPath = await resolveRouteOptionsEntryPath(outputDir, outputName) try { await importWithCacheBust(entryPath) @@ -173,7 +173,7 @@ async function findEntryFile( return undefined } - const matches = new Set() + const matches: Array = [] for (const entry of entries) { const entryPath = join(directory, entry.name) @@ -181,7 +181,7 @@ async function findEntryFile( if (entry.isDirectory()) { const match = await findEntryFile(entryPath, outputName) if (match) { - matches.add(match) + matches.push(match) } continue } @@ -193,15 +193,15 @@ async function findEntryFile( const name = basename(entry.name, ext) if (name === outputName || name.startsWith(`${outputName}-`)) { - matches.add(entryPath) + matches.push(entryPath) } } - if (matches.size === 1) { - return Array.from(matches)[0] + if (matches.length === 1) { + return matches[0] } - if (matches.size > 1) { + if (matches.length > 1) { throw new Error( `Unable to resolve a unique Vite route-options entry ${outputName} in ${directory}`, ) @@ -216,11 +216,9 @@ async function importWithCacheBust(path: string) { await import(url.toString()) } -function getRouteOptionsEntry( - input: unknown, -): { outputName: string } | undefined { +function getRouteOptionsEntryName(input: unknown): string | undefined { if (typeof input === 'string') { - return { outputName: basename(input, extname(input)) } + return basename(input, extname(input)) } if (input && typeof input === 'object') { @@ -229,13 +227,13 @@ function getRouteOptionsEntry( ) if (entries.length === 1) { - return { outputName: entries[0]![0] } + return entries[0]![0] } const serverEntry = entries.find(([name]) => name === 'server') if (serverEntry) { - return { outputName: serverEntry[0] } + return serverEntry[0] } throw new Error('Unable to resolve Vite route-options entry point') diff --git a/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts index 539ac910df2..dd19d725768 100644 --- a/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts @@ -15,7 +15,7 @@ import { SERVER_PROP, SERVER_ROUTE_OPTION_DELETE_NODES, } from '../../start-router-plugin/constants' -import { shouldUseSeparatePrerenderRouteOptions } from '../../prerender-route-options-env' +import { shouldSeparateRouteOptions } from '../../prerender-route-options-env' import type { GetConfigFn } from '../../types' import type { TanStackStartVitePluginCoreOptions } from '../types' import type { @@ -152,7 +152,6 @@ export function tanStackStartRouter( tanstackRouterGenerator(() => { const routerConfig = getConfig().startConfig.router const plugins = [clientTreeGeneratorPlugin, routesManifestPlugin()] - // Dynamic route params can enable prerendering after route generation. if (startPluginOpts.prerender?.enabled !== false) { plugins.push(prerenderRoutesPlugin()) } @@ -178,14 +177,13 @@ export function tanStackStartRouter( } }, routerPluginContext), tanStackRouterCodeSplitter(() => { - const routerConfig = getConfig().startConfig.router + const { startConfig } = getConfig() + const routerConfig = startConfig.router return { ...routerConfig, codeSplittingOptions: { ...routerConfig.codeSplittingOptions, - deleteNodes: shouldUseSeparatePrerenderRouteOptions( - getConfig().startConfig, - ) + deleteNodes: shouldSeparateRouteOptions(startConfig) ? SERVER_ROUTE_OPTION_DELETE_NODES : undefined, addHmr: false, diff --git a/packages/start-plugin-core/tests/prerender-params-runner.test.ts b/packages/start-plugin-core/tests/prerender-params-runner.test.ts index a7e96898944..a6da2cd9279 100644 --- a/packages/start-plugin-core/tests/prerender-params-runner.test.ts +++ b/packages/start-plugin-core/tests/prerender-params-runner.test.ts @@ -489,6 +489,42 @@ describe('runPrerenderParams', () => { ).rejects.toThrow('Missing prerenderParams values for route /posts/$slug') }) + it('throws when prerenderParams does not return an array', async () => { + const routeTree = createRouteTree({ + '/posts/$slug': { + prerenderParams: () => undefined, + }, + }) + + await expect( + runPrerenderParams({ + routeTree, + pages: [], + logger, + }), + ).rejects.toThrow( + 'prerenderParams for route /posts/$slug must return an array', + ) + }) + + it('throws when a prerenderParams entry does not include params', async () => { + const routeTree = createRouteTree({ + '/posts/$slug': { + prerenderParams: () => [{}], + }, + }) + + await expect( + runPrerenderParams({ + routeTree, + pages: [], + logger, + }), + ).rejects.toThrow( + 'prerenderParams entry for route /posts/$slug must include params', + ) + }) + it('throws when a prerenderParams entry has nullish required params', async () => { const routeTree = createRouteTree({ '/posts/$slug': { diff --git a/packages/start-plugin-core/tests/prerender-route-options-env.test.ts b/packages/start-plugin-core/tests/prerender-route-options-env.test.ts index 8a27515b30f..c7c2e91a88d 100644 --- a/packages/start-plugin-core/tests/prerender-route-options-env.test.ts +++ b/packages/start-plugin-core/tests/prerender-route-options-env.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { - applySeparatePrerenderRouteOptionsBundleDefault, - shouldUseSeparatePrerenderRouteOptions, + applySeparateRouteOptionsDefault, + shouldSeparateRouteOptions, } from '../src/prerender-route-options-env' import { parseStartConfig } from '../src/schema' @@ -13,7 +13,7 @@ describe('separate prerender route options environment', () => { process.cwd(), ) - expect(shouldUseSeparatePrerenderRouteOptions(startConfig)).toBe(true) + expect(shouldSeparateRouteOptions(startConfig)).toBe(true) }) it('can be disabled to keep route options in the final server bundle', () => { @@ -28,7 +28,7 @@ describe('separate prerender route options environment', () => { process.cwd(), ) - expect(shouldUseSeparatePrerenderRouteOptions(startConfig)).toBe(false) + expect(shouldSeparateRouteOptions(startConfig)).toBe(false) }) it('can be disabled by a deployment-specific default', () => { @@ -38,9 +38,9 @@ describe('separate prerender route options environment', () => { process.cwd(), ) - applySeparatePrerenderRouteOptionsBundleDefault(startConfig, false) + applySeparateRouteOptionsDefault(startConfig, false) - expect(shouldUseSeparatePrerenderRouteOptions(startConfig)).toBe(false) + expect(shouldSeparateRouteOptions(startConfig)).toBe(false) }) it('preserves explicit user configuration over deployment defaults', () => { @@ -55,12 +55,12 @@ describe('separate prerender route options environment', () => { process.cwd(), ) - applySeparatePrerenderRouteOptionsBundleDefault(startConfig, false) + applySeparateRouteOptionsDefault(startConfig, false) - expect(shouldUseSeparatePrerenderRouteOptions(startConfig)).toBe(true) + expect(shouldSeparateRouteOptions(startConfig)).toBe(true) }) - it('stays disabled for SPA prerendering', () => { + it('is enabled for SPA builds so final server output is stripped', () => { const startConfig = parseStartConfig( { spa: { enabled: true }, @@ -70,6 +70,6 @@ describe('separate prerender route options environment', () => { process.cwd(), ) - expect(shouldUseSeparatePrerenderRouteOptions(startConfig)).toBe(false) + expect(shouldSeparateRouteOptions(startConfig)).toBe(true) }) }) diff --git a/packages/start-plugin-core/tests/prerender-ssrf.test.ts b/packages/start-plugin-core/tests/prerender-ssrf.test.ts index d85191f91e1..18c8885dc52 100644 --- a/packages/start-plugin-core/tests/prerender-ssrf.test.ts +++ b/packages/start-plugin-core/tests/prerender-ssrf.test.ts @@ -39,6 +39,7 @@ const handler = { function resetFetch() { fetchMock.mockClear() + globalThis.TSS_PRERENDER_ROUTE_TREE = async () => undefined } function makeStartConfig(pagePath: string) { @@ -73,6 +74,16 @@ describe('prerender pages validation', () => { expect(fetchMock).not.toHaveBeenCalled() }) + it('throws when route options are not loaded for SSR prerendering', async () => { + resetFetch() + delete globalThis.TSS_PRERENDER_ROUTE_TREE + const startConfig = makeStartConfig('/about') + + await expect(prerender({ startConfig, handler })).rejects.toThrow( + 'Prerender route options were not loaded', + ) + }) + it('closes the handler and clears route tree state when prerendering fails', async () => { resetFetch() const startConfig = makeStartConfig('https://attacker.test/leak') diff --git a/packages/start-plugin-core/tests/vite-prerender.test.ts b/packages/start-plugin-core/tests/vite-prerender.test.ts index 4ec24c42c62..8b83e967b3f 100644 --- a/packages/start-plugin-core/tests/vite-prerender.test.ts +++ b/packages/start-plugin-core/tests/vite-prerender.test.ts @@ -114,6 +114,110 @@ describe('prerenderWithVite', () => { await rm(root, { recursive: true, force: true }) } }) + + it('imports route options from the server bundle when separation is disabled', async () => { + const root = await mkdtemp(join(tmpdir(), 'tss-vite-prerender-')) + const clientOutputDirectory = join(root, 'client') + const serverOutputDirectory = join(root, 'server') + const prerenderOutputDirectory = join(serverOutputDirectory, '.tanstack/prerender') + const serverRouteOptionsDirectory = join(serverOutputDirectory, 'server') + const close = vi.fn() + const prerenderSpy = vi.fn(async ({ handler }: any) => { + expect((globalThis as any).__ROUTE_OPTIONS_LOADED).toBe('server') + await handler.close() + }) + + vi.doMock('vite', () => ({ + preview: vi.fn(async () => ({ + resolvedUrls: { local: ['http://127.0.0.1:4173/'] }, + close, + })), + })) + vi.doMock('../src/prerender', async () => { + const actual = await vi.importActual('../src/prerender') + return { + ...actual, + prerender: prerenderSpy, + } + }) + + await mkdir(serverRouteOptionsDirectory, { recursive: true }) + await mkdir(prerenderOutputDirectory, { recursive: true }) + await writeFile( + join(serverRouteOptionsDirectory, 'server.js'), + 'globalThis.__ROUTE_OPTIONS_LOADED = "server"', + ) + await writeFile( + join(prerenderOutputDirectory, 'server.js'), + 'throw new Error("should not load prerender bundle")', + ) + + const { prerenderWithVite } = await import('../src/vite/prerender') + + try { + await prerenderWithVite({ + startConfig: createStartConfig(false), + builder: createBuilder({ + clientOutputDirectory, + serverOutputDirectory, + prerenderOutputDirectory, + }), + } as any) + + expect(prerenderSpy).toHaveBeenCalledOnce() + expect(close).toHaveBeenCalledOnce() + expect(process.env.TSS_PRERENDERING).toBeUndefined() + expect(process.env.TSS_CLIENT_OUTPUT_DIR).toBeUndefined() + expect(globalThis.TSS_PRERENDER_ROUTE_TREE).toBeUndefined() + await expect(access(prerenderOutputDirectory)).resolves.toBeUndefined() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('cleans up when the separate route-options environment is missing', async () => { + const root = await mkdtemp(join(tmpdir(), 'tss-vite-prerender-')) + const clientOutputDirectory = join(root, 'client') + const serverOutputDirectory = join(root, 'server') + const prerenderOutputDirectory = join(serverOutputDirectory, '.tanstack/prerender') + const prerenderSpy = vi.fn() + const preview = vi.fn() + + vi.doMock('vite', () => ({ preview })) + vi.doMock('../src/prerender', async () => { + const actual = await vi.importActual('../src/prerender') + return { + ...actual, + prerender: prerenderSpy, + } + }) + + const builder = createBuilder({ + clientOutputDirectory, + serverOutputDirectory, + prerenderOutputDirectory, + }) + delete (builder.environments as any).prerender + + const { prerenderWithVite } = await import('../src/vite/prerender') + + try { + await expect( + prerenderWithVite({ + startConfig: createStartConfig(true), + builder, + } as any), + ).rejects.toThrow('Vite\'s "prerender" environment not found') + + expect(preview).not.toHaveBeenCalled() + expect(prerenderSpy).not.toHaveBeenCalled() + expect(process.env.TSS_PRERENDERING).toBeUndefined() + expect(process.env.TSS_CLIENT_OUTPUT_DIR).toBeUndefined() + expect(globalThis.TSS_PRERENDER_ROUTE_TREE).toBeUndefined() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) }) function createStartConfig(separateRouteOptionsBundle: boolean) { From 8efa849b5bbd71cf368d474f190d66e0b935fef8 Mon Sep 17 00:00:00 2001 From: jon Date: Tue, 5 May 2026 23:08:23 +0100 Subject: [PATCH 07/11] test: assert prerender route options are stripped --- .../src/routes/prerender-params.$slug.tsx | 45 ++++++++++- .../basic/tests/prerendering.spec.ts | 76 ++++++++++++++++--- 2 files changed, 111 insertions(+), 10 deletions(-) diff --git a/e2e/react-start/basic/src/routes/prerender-params.$slug.tsx b/e2e/react-start/basic/src/routes/prerender-params.$slug.tsx index 96e42365584..a5f9a31a2af 100644 --- a/e2e/react-start/basic/src/routes/prerender-params.$slug.tsx +++ b/e2e/react-start/basic/src/routes/prerender-params.$slug.tsx @@ -1,6 +1,25 @@ import { createFileRoute } from '@tanstack/react-router' import z from 'zod' -import { getServerOnlyPrerenderSlug } from './-prerender-params.server' +import { + SERVER_ONLY_PRERENDER_MARKER, + getServerOnlyPrerenderSlug, +} from './-prerender-params.server' + +const topLevelPrerenderLiteral = + 'top-level-prerender-literal-marker-should-not-ship' +const topLevelPrerenderImportedMarker = SERVER_ONLY_PRERENDER_MARKER.replace( + 'server-only-prerender-marker-should-not-be-in-client', + 'top-level-imported-marker-slug', +) +const topLevelPrerenderImportedCall = getServerOnlyPrerenderSlug().replace( + 'server-only-slug', + 'top-level-import-call-marker-should-not-ship', +) +const topLevelPrerenderSideEffect = (() => { + ;(globalThis as any).__TSR_PRERENDER_SIDE_EFFECT_MARKER = + 'top-level-side-effect-prerender-marker-should-not-ship' + return 'top-level-side-effect-slug' +})() export const Route = createFileRoute('/prerender-params/$slug')({ validateSearch: z.object({ @@ -43,6 +62,30 @@ export const Route = createFileRoute('/prerender-params/$slug')({ exclude: true, }, }, + { + params: { slug: topLevelPrerenderLiteral }, + sitemap: { + exclude: true, + }, + }, + { + params: { slug: topLevelPrerenderImportedMarker }, + sitemap: { + exclude: true, + }, + }, + { + params: { slug: topLevelPrerenderImportedCall }, + sitemap: { + exclude: true, + }, + }, + { + params: { slug: topLevelPrerenderSideEffect }, + sitemap: { + exclude: true, + }, + }, ], component: RouteComponent, }) diff --git a/e2e/react-start/basic/tests/prerendering.spec.ts b/e2e/react-start/basic/tests/prerendering.spec.ts index 90ef4327a5f..a9488aca984 100644 --- a/e2e/react-start/basic/tests/prerendering.spec.ts +++ b/e2e/react-start/basic/tests/prerendering.spec.ts @@ -9,6 +9,32 @@ const distDir = join( process.env.E2E_DIST_DIR ?? 'dist', 'client', ) +const serverDistDir = join( + process.cwd(), + process.env.E2E_DIST_DIR ?? 'dist', + 'server', +) + +const prerenderOnlyBundleMarkers = [ + 'server-only-prerender-marker', + 'top-level-prerender-literal-marker-should-not-ship', + 'top-level-imported-marker-slug', + 'top-level-import-call-marker-should-not-ship', + 'top-level-side-effect-prerender-marker-should-not-ship', + 'top-level-side-effect-slug', + '__TSR_PRERENDER_SIDE_EFFECT_MARKER', +] as const + +function outputContainsMarker(dir: string, marker: string) { + return readdirSync(dir, { recursive: true }).some((relativePath) => { + const filePath = join(dir, String(relativePath)) + return ( + statSync(filePath).isFile() && + filePath.endsWith('.js') && + readFileSync(filePath, 'utf-8').includes(marker) + ) + }) +} test.describe('Prerender Static Path Discovery', () => { test.skip(!isPrerender, 'Skipping since not in prerender mode') @@ -124,18 +150,50 @@ test.describe('Prerender Static Path Discovery', () => { expect(existsSync(htmlPath)).toBe(true) expect( - readdirSync(distDir, { recursive: true }).some((relativePath) => { - const filePath = join(distDir, String(relativePath)) - return ( - statSync(filePath).isFile() && - readFileSync(filePath, 'utf-8').includes( - 'server-only-prerender-marker', - ) - ) - }), + outputContainsMarker(distDir, 'server-only-prerender-marker'), ).toBe(false) }) + test('should strip prerenderParams-only module scope code from final bundles', () => { + expect( + existsSync( + join( + distDir, + 'prerender-params/top-level-prerender-literal-marker-should-not-ship/index.html', + ), + ), + ).toBe(true) + expect( + existsSync( + join( + distDir, + 'prerender-params/top-level-imported-marker-slug/index.html', + ), + ), + ).toBe(true) + expect( + existsSync( + join( + distDir, + 'prerender-params/top-level-import-call-marker-should-not-ship/index.html', + ), + ), + ).toBe(true) + expect( + existsSync( + join( + distDir, + 'prerender-params/top-level-side-effect-slug/index.html', + ), + ), + ).toBe(true) + + for (const marker of prerenderOnlyBundleMarkers) { + expect(outputContainsMarker(distDir, marker)).toBe(false) + expect(outputContainsMarker(serverDistDir, marker)).toBe(false) + } + }) + test('should include route sitemap options from prerenderParams', () => { const sitemapPath = join(distDir, 'sitemap.xml') From 75a036452e6d72490db026a4841a1a100eb83b4d Mon Sep 17 00:00:00 2001 From: jon Date: Tue, 5 May 2026 23:20:06 +0100 Subject: [PATCH 08/11] fix: remove nitro route options special case --- .../src/prerender-route-options-env.ts | 14 -------- packages/start-plugin-core/src/vite/nitro.ts | 31 ----------------- packages/start-plugin-core/src/vite/plugin.ts | 10 +----- .../tests/prerender-route-options-env.test.ts | 34 +------------------ .../tests/vite-nitro.test.ts | 16 --------- .../tests/vite-planning.test.ts | 2 +- 6 files changed, 3 insertions(+), 104 deletions(-) delete mode 100644 packages/start-plugin-core/src/vite/nitro.ts delete mode 100644 packages/start-plugin-core/tests/vite-nitro.test.ts diff --git a/packages/start-plugin-core/src/prerender-route-options-env.ts b/packages/start-plugin-core/src/prerender-route-options-env.ts index 9090644e070..51ae3a8ed13 100644 --- a/packages/start-plugin-core/src/prerender-route-options-env.ts +++ b/packages/start-plugin-core/src/prerender-route-options-env.ts @@ -26,20 +26,6 @@ export function restorePrerenderEnv(state: PrerenderEnvState) { } } -export function applySeparateRouteOptionsDefault( - startConfig: TanStackStartOutputConfig, - defaultValue: boolean, -) { - if (startConfig.prerender?.separateRouteOptionsBundle !== undefined) { - return - } - - startConfig.prerender = { - ...startConfig.prerender, - separateRouteOptionsBundle: defaultValue, - } -} - export function shouldSeparateRouteOptions( startConfig: TanStackStartOutputConfig, ) { diff --git a/packages/start-plugin-core/src/vite/nitro.ts b/packages/start-plugin-core/src/vite/nitro.ts deleted file mode 100644 index ed04f5fb117..00000000000 --- a/packages/start-plugin-core/src/vite/nitro.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { PluginOption } from 'vite' - -export function hasNitroPlugin( - plugins: PluginOption | Array | undefined, -) { - if (!plugins) { - return false - } - - for (const plugin of Array.isArray(plugins) ? plugins : [plugins]) { - if (!plugin) { - continue - } - - if (Array.isArray(plugin)) { - if (hasNitroPlugin(plugin)) { - return true - } - continue - } - - if (typeof plugin === 'object' && 'name' in plugin) { - const name = plugin.name - if (typeof name === 'string' && name.startsWith('nitro:')) { - return true - } - } - } - - return false -} diff --git a/packages/start-plugin-core/src/vite/plugin.ts b/packages/start-plugin-core/src/vite/plugin.ts index c653b3c911b..a8b3b62c15b 100644 --- a/packages/start-plugin-core/src/vite/plugin.ts +++ b/packages/start-plugin-core/src/vite/plugin.ts @@ -10,10 +10,7 @@ import { normalizePublicBase, shouldRewriteDevBasepath, } from '../planning' -import { - applySeparateRouteOptionsDefault, - shouldSeparateRouteOptions, -} from '../prerender-route-options-env' +import { shouldSeparateRouteOptions } from '../prerender-route-options-env' import { importProtectionPlugin } from './import-protection-plugin/plugin' import { startCompilerPlugin } from './start-compiler-plugin/plugin' import { loadEnvPlugin } from './load-env-plugin/plugin' @@ -37,7 +34,6 @@ import { getClientOutputDirectory, getServerOutputDirectory, } from './output-directory' -import { hasNitroPlugin } from './nitro' import { postServerBuild } from './post-server-build' import { serializationAdaptersPlugin } from './serialization-adapters-plugin' import type { @@ -111,10 +107,6 @@ export function tanStackStartVite( serverOutputDirectory: getServerOutputDirectory(viteConfig), }) const { startConfig } = getConfig() - applySeparateRouteOptionsDefault( - startConfig, - !hasNitroPlugin(viteConfig.plugins), - ) const separateRouteOptions = shouldSeparateRouteOptions(startConfig) const routerBasepath = applyResolvedRouterBasepath({ resolvedStartConfig, diff --git a/packages/start-plugin-core/tests/prerender-route-options-env.test.ts b/packages/start-plugin-core/tests/prerender-route-options-env.test.ts index c7c2e91a88d..939efd47dc7 100644 --- a/packages/start-plugin-core/tests/prerender-route-options-env.test.ts +++ b/packages/start-plugin-core/tests/prerender-route-options-env.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - applySeparateRouteOptionsDefault, - shouldSeparateRouteOptions, -} from '../src/prerender-route-options-env' +import { shouldSeparateRouteOptions } from '../src/prerender-route-options-env' import { parseStartConfig } from '../src/schema' describe('separate prerender route options environment', () => { @@ -31,35 +28,6 @@ describe('separate prerender route options environment', () => { expect(shouldSeparateRouteOptions(startConfig)).toBe(false) }) - it('can be disabled by a deployment-specific default', () => { - const startConfig = parseStartConfig( - { prerender: { enabled: true } }, - { framework: 'react' }, - process.cwd(), - ) - - applySeparateRouteOptionsDefault(startConfig, false) - - expect(shouldSeparateRouteOptions(startConfig)).toBe(false) - }) - - it('preserves explicit user configuration over deployment defaults', () => { - const startConfig = parseStartConfig( - { - prerender: { - enabled: true, - separateRouteOptionsBundle: true, - }, - }, - { framework: 'react' }, - process.cwd(), - ) - - applySeparateRouteOptionsDefault(startConfig, false) - - expect(shouldSeparateRouteOptions(startConfig)).toBe(true) - }) - it('is enabled for SPA builds so final server output is stripped', () => { const startConfig = parseStartConfig( { diff --git a/packages/start-plugin-core/tests/vite-nitro.test.ts b/packages/start-plugin-core/tests/vite-nitro.test.ts deleted file mode 100644 index f9f03bc4fbd..00000000000 --- a/packages/start-plugin-core/tests/vite-nitro.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { hasNitroPlugin } from '../src/vite/nitro' - -describe('hasNitroPlugin', () => { - it('detects Nitro Vite plugin entries', () => { - expect(hasNitroPlugin([{ name: 'nitro:env' }])).toBe(true) - }) - - it('detects nested Nitro Vite plugin entries', () => { - expect(hasNitroPlugin([[{ name: 'nitro:env' }]])).toBe(true) - }) - - it('ignores non-Nitro plugins', () => { - expect(hasNitroPlugin([{ name: 'vite:react' }])).toBe(false) - }) -}) diff --git a/packages/start-plugin-core/tests/vite-planning.test.ts b/packages/start-plugin-core/tests/vite-planning.test.ts index daf2ae17614..d134b9958d1 100644 --- a/packages/start-plugin-core/tests/vite-planning.test.ts +++ b/packages/start-plugin-core/tests/vite-planning.test.ts @@ -5,7 +5,7 @@ import { } from '../src/vite/planning' describe('Vite planning', () => { - it('uses a non-Nitro-service input for the prerender route options environment', () => { + it('uses the server entry input for the prerender route options environment', () => { const entryAliases = createViteResolvedEntryAliases({ entryPaths: { client: '/app/src/client.tsx', From 1c2726137af2b84da7034b3a73a48f33d1a432e2 Mon Sep 17 00:00:00 2001 From: jon Date: Wed, 6 May 2026 08:57:31 +0100 Subject: [PATCH 09/11] test: cover sitemap host edge cases --- .../react/guide/static-prerendering.md | 2 +- .../solid/guide/static-prerendering.md | 2 +- .../tests/build-sitemap.test.ts | 91 ++++++++++++++++++- 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/docs/start/framework/react/guide/static-prerendering.md b/docs/start/framework/react/guide/static-prerendering.md index cbca449c058..97f3c5c8824 100644 --- a/docs/start/framework/react/guide/static-prerendering.md +++ b/docs/start/framework/react/guide/static-prerendering.md @@ -75,7 +75,7 @@ export default defineConfig({ }) ``` -By default, Start builds route options used by `prerenderParams` and `sitemap` separately from the final server bundle so they can be used at build time without being deployed. Some deployment adapters may override this default for compatibility. For example, Nitro keeps route options in the server bundle unless you explicitly set `prerender.separateRouteOptionsBundle` to `true`. Set `prerender.separateRouteOptionsBundle` to `false` if your deployment adapter does not support the extra build environment or if you prefer to keep those route options in the server bundle. +By default, Start builds route options used by `prerenderParams` and `sitemap` separately from the final server bundle so they can be used at build time without being deployed. Set `prerender.separateRouteOptionsBundle` to `false` if your deployment adapter does not support the extra build environment or if you prefer to keep those route options in the server bundle. ## Automatic Static Route Discovery diff --git a/docs/start/framework/solid/guide/static-prerendering.md b/docs/start/framework/solid/guide/static-prerendering.md index df9d78cef0d..6f05eeb0b4f 100644 --- a/docs/start/framework/solid/guide/static-prerendering.md +++ b/docs/start/framework/solid/guide/static-prerendering.md @@ -75,7 +75,7 @@ export default defineConfig({ }) ``` -By default, Start builds route options used by `prerenderParams` and `sitemap` separately from the final server bundle so they can be used at build time without being deployed. Some deployment adapters may override this default for compatibility. For example, Nitro keeps route options in the server bundle unless you explicitly set `prerender.separateRouteOptionsBundle` to `true`. Set `prerender.separateRouteOptionsBundle` to `false` if your deployment adapter does not support the extra build environment or if you prefer to keep those route options in the server bundle. +By default, Start builds route options used by `prerenderParams` and `sitemap` separately from the final server bundle so they can be used at build time without being deployed. Set `prerender.separateRouteOptionsBundle` to `false` if your deployment adapter does not support the extra build environment or if you prefer to keep those route options in the server bundle. ## Automatic Static Route Discovery diff --git a/packages/start-plugin-core/tests/build-sitemap.test.ts b/packages/start-plugin-core/tests/build-sitemap.test.ts index 2f855b3bb24..57f538981a0 100644 --- a/packages/start-plugin-core/tests/build-sitemap.test.ts +++ b/packages/start-plugin-core/tests/build-sitemap.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -89,6 +89,95 @@ describe('buildSitemap', () => { expect(pagesJson).toContain('/blog/router?tag=router+start') }) + it('preserves a deployment base path from the sitemap host', () => { + const publicDir = mkdtempSync(join(tmpdir(), 'tanstack-start-sitemap-')) + tempDirs.push(publicDir) + + buildSitemap({ + publicDir, + startConfig: { + sitemap: { + enabled: true, + host: 'https://example.com/docs/', + outputPath: 'sitemap.xml', + }, + pages: [{ path: '/guide/start' }], + } as any, + }) + + const sitemap = readFileSync(join(publicDir, 'sitemap.xml'), 'utf-8') + + expect(sitemap).toContain('https://example.com/docs/guide/start') + expect(sitemap).not.toContain('https://example.com/docs//guide/start') + }) + + it('uses a host supplied from the environment', () => { + const publicDir = mkdtempSync(join(tmpdir(), 'tanstack-start-sitemap-')) + tempDirs.push(publicDir) + + const previousSiteUrl = process.env.SITE_URL + process.env.SITE_URL = 'https://deploy.example.com' + + try { + buildSitemap({ + publicDir, + startConfig: { + sitemap: { + enabled: true, + host: process.env.SITE_URL, + outputPath: 'sitemap.xml', + }, + pages: [{ path: '/guide/start' }], + } as any, + }) + } finally { + if (previousSiteUrl === undefined) { + delete process.env.SITE_URL + } else { + process.env.SITE_URL = previousSiteUrl + } + } + + const sitemap = readFileSync(join(publicDir, 'sitemap.xml'), 'utf-8') + + expect(sitemap).toContain( + 'https://deploy.example.com/guide/start', + ) + }) + + it('skips sitemap generation when pages exist but sitemap config is omitted', () => { + const publicDir = mkdtempSync(join(tmpdir(), 'tanstack-start-sitemap-')) + tempDirs.push(publicDir) + + buildSitemap({ + publicDir, + startConfig: { + pages: [{ path: '/guide/start' }], + } as any, + }) + + expect(existsSync(join(publicDir, 'sitemap.xml'))).toBe(false) + expect(existsSync(join(publicDir, 'pages.json'))).toBe(false) + }) + + it('throws when sitemap is explicitly enabled without a host', () => { + const publicDir = mkdtempSync(join(tmpdir(), 'tanstack-start-sitemap-')) + tempDirs.push(publicDir) + + expect(() => + buildSitemap({ + publicDir, + startConfig: { + sitemap: { + enabled: true, + outputPath: 'sitemap.xml', + }, + pages: [{ path: '/guide/start' }], + } as any, + }), + ).toThrow('Sitemap host is not set and required to build the sitemap.') + }) + it('writes advanced sitemap metadata', () => { const publicDir = mkdtempSync(join(tmpdir(), 'tanstack-start-sitemap-')) tempDirs.push(publicDir) From 0c80382b11af88e892d47b021c7b8266bb2620b8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 10:28:17 +0000 Subject: [PATCH 10/11] ci: apply automated fixes --- .../src/prerender-params-runner.ts | 4 ++- .../start-plugin-core/src/rsbuild/plugin.ts | 5 ++-- .../src/rsbuild/post-build.ts | 7 ++++-- .../src/rsbuild/virtual-modules.ts | 3 ++- .../start-plugin-core/src/vite/planning.ts | 5 +--- .../src/vite/start-compiler-plugin/plugin.ts | 5 +++- .../tests/prerender-routes-plugin.test.ts | 1 - .../tests/rsbuild-post-build.test.ts | 10 ++++++-- .../tests/vite-prerender.test.ts | 25 +++++++++++++++---- 9 files changed, 46 insertions(+), 19 deletions(-) diff --git a/packages/start-plugin-core/src/prerender-params-runner.ts b/packages/start-plugin-core/src/prerender-params-runner.ts index 959d27ca0af..00474ff32a2 100644 --- a/packages/start-plugin-core/src/prerender-params-runner.ts +++ b/packages/start-plugin-core/src/prerender-params-runner.ts @@ -129,7 +129,9 @@ function startPrerenderParamsTimeout( } if (!Number.isFinite(timeout) || timeout < 0) { - throw new Error('prerenderParamsTimeout must be a non-negative finite number') + throw new Error( + 'prerenderParamsTimeout must be a non-negative finite number', + ) } const timeoutId = setTimeout(() => { diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts index 0aebabada44..91abf93aadf 100644 --- a/packages/start-plugin-core/src/rsbuild/plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/plugin.ts @@ -165,8 +165,9 @@ export function tanStackStartRsbuild( prerenderOutputDirectory = separateRouteOptions ? resolveRsbuildOutputDirectory({ distPath: - environmentPlan.environments[RSBUILD_ENVIRONMENT_NAMES.prerender] - ?.output?.distPath, + environmentPlan.environments[ + RSBUILD_ENVIRONMENT_NAMES.prerender + ]?.output?.distPath, rootDistPath: undefined, fallback: join( resolvedStartConfig.outputDirectories.server, diff --git a/packages/start-plugin-core/src/rsbuild/post-build.ts b/packages/start-plugin-core/src/rsbuild/post-build.ts index 0128190aa2c..6316e75a505 100644 --- a/packages/start-plugin-core/src/rsbuild/post-build.ts +++ b/packages/start-plugin-core/src/rsbuild/post-build.ts @@ -128,14 +128,17 @@ async function createRsbuildPrerenderHandler({ function getPrerenderOutputDirectory() { return ( - prerenderOutputDirectory ?? join(serverOutputDirectory, '.tanstack/prerender') + prerenderOutputDirectory ?? + join(serverOutputDirectory, '.tanstack/prerender') ) } } async function loadRouteOptionsFromBundle(prerenderOutputDirectory: string) { const { pathToFileURL } = await import('node:url') - const prerenderEntryUrl = pathToFileURL(join(prerenderOutputDirectory, 'index.js')) + const prerenderEntryUrl = pathToFileURL( + join(prerenderOutputDirectory, 'index.js'), + ) prerenderEntryUrl.searchParams.set('tss-prerender', Date.now().toString()) delete globalThis.TSS_PRERENDER_ROUTE_TREE diff --git a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts index 2ec6839b984..5abee50bc2c 100644 --- a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts +++ b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts @@ -374,7 +374,8 @@ export function registerVirtualModules( startConfig.server.build.inlineCss, ) } else { - content[paths.manifest] = `export const tsrStartManifest = () => ({ routes: {}, clientEntry: '' })` + content[paths.manifest] = + `export const tsrStartManifest = () => ({ routes: {}, clientEntry: '' })` } // Injected head scripts — only server diff --git a/packages/start-plugin-core/src/vite/planning.ts b/packages/start-plugin-core/src/vite/planning.ts index c0450edd2b2..11c9eb419e1 100644 --- a/packages/start-plugin-core/src/vite/planning.ts +++ b/packages/start-plugin-core/src/vite/planning.ts @@ -110,10 +110,7 @@ export function createViteConfigPlan(opts: { : serverInput, [/^cloudflare:/], ), - outDir: join( - opts.serverOutputDirectory, - '.tanstack/prerender', - ), + outDir: join(opts.serverOutputDirectory, '.tanstack/prerender'), commonjsOptions: { include: [/node_modules/], }, diff --git a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts index 18b295543aa..3e844fad892 100644 --- a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts @@ -496,7 +496,10 @@ export function startCompilerPlugin( load() { if ( this.environment.name !== opts.providerEnvName && - !(ssrIsProvider && this.environment.name === START_ENVIRONMENT_NAMES.prerender) + !( + ssrIsProvider && + this.environment.name === START_ENVIRONMENT_NAMES.prerender + ) ) { const mod = opts.environments.find( (e) => e.name === this.environment.name, diff --git a/packages/start-plugin-core/tests/prerender-routes-plugin.test.ts b/packages/start-plugin-core/tests/prerender-routes-plugin.test.ts index 9523a772e58..7d365b8fc94 100644 --- a/packages/start-plugin-core/tests/prerender-routes-plugin.test.ts +++ b/packages/start-plugin-core/tests/prerender-routes-plugin.test.ts @@ -62,5 +62,4 @@ describe('prerenderRoutesPlugin', () => { expect(globalThis.TSS_PRERENDABLE_PATHS).toEqual([{ path: '/' }]) }) - }) diff --git a/packages/start-plugin-core/tests/rsbuild-post-build.test.ts b/packages/start-plugin-core/tests/rsbuild-post-build.test.ts index 7c07805f505..404e8d530ff 100644 --- a/packages/start-plugin-core/tests/rsbuild-post-build.test.ts +++ b/packages/start-plugin-core/tests/rsbuild-post-build.test.ts @@ -67,7 +67,10 @@ describe('postBuildWithRsbuild', () => { it('imports route options from the prerender bundle and removes it', async () => { const serverOutputDirectory = await mkdtemp(join(tmpdir(), 'tss-rsbuild-')) - const prerenderOutputDirectory = join(serverOutputDirectory, 'custom-prerender') + const prerenderOutputDirectory = join( + serverOutputDirectory, + 'custom-prerender', + ) const prerenderSpy = vi.fn(async ({ handler }: any) => { expect((globalThis as any).__ROUTE_OPTIONS_LOADED).toBe(true) const response = await handler.request('/posts') @@ -127,7 +130,10 @@ describe('postBuildWithRsbuild', () => { it('cleans up route options and env vars if request handler preload fails', async () => { const serverOutputDirectory = await mkdtemp(join(tmpdir(), 'tss-rsbuild-')) - const prerenderOutputDirectory = join(serverOutputDirectory, 'custom-prerender') + const prerenderOutputDirectory = join( + serverOutputDirectory, + 'custom-prerender', + ) const prerenderSpy = vi.fn() vi.doMock('../src/prerender', async () => { diff --git a/packages/start-plugin-core/tests/vite-prerender.test.ts b/packages/start-plugin-core/tests/vite-prerender.test.ts index 8b83e967b3f..e97a321f6e7 100644 --- a/packages/start-plugin-core/tests/vite-prerender.test.ts +++ b/packages/start-plugin-core/tests/vite-prerender.test.ts @@ -20,7 +20,10 @@ describe('prerenderWithVite', () => { const root = await mkdtemp(join(tmpdir(), 'tss-vite-prerender-')) const clientOutputDirectory = join(root, 'client') const serverOutputDirectory = join(root, 'server') - const prerenderOutputDirectory = join(serverOutputDirectory, '.tanstack/prerender') + const prerenderOutputDirectory = join( + serverOutputDirectory, + '.tanstack/prerender', + ) const close = vi.fn() const prerenderSpy = vi.fn(async ({ handler }: any) => { expect((globalThis as any).__ROUTE_OPTIONS_LOADED).toBe(1) @@ -74,7 +77,10 @@ describe('prerenderWithVite', () => { const root = await mkdtemp(join(tmpdir(), 'tss-vite-prerender-')) const clientOutputDirectory = join(root, 'client') const serverOutputDirectory = join(root, 'server') - const prerenderOutputDirectory = join(serverOutputDirectory, '.tanstack/prerender') + const prerenderOutputDirectory = join( + serverOutputDirectory, + '.tanstack/prerender', + ) const prerenderSpy = vi.fn() vi.doMock('vite', () => ({ @@ -89,7 +95,10 @@ describe('prerenderWithVite', () => { }) await mkdir(prerenderOutputDirectory, { recursive: true }) - await writeFile(join(prerenderOutputDirectory, 'server.js'), 'throw new Error("boom")') + await writeFile( + join(prerenderOutputDirectory, 'server.js'), + 'throw new Error("boom")', + ) const { prerenderWithVite } = await import('../src/vite/prerender') @@ -119,7 +128,10 @@ describe('prerenderWithVite', () => { const root = await mkdtemp(join(tmpdir(), 'tss-vite-prerender-')) const clientOutputDirectory = join(root, 'client') const serverOutputDirectory = join(root, 'server') - const prerenderOutputDirectory = join(serverOutputDirectory, '.tanstack/prerender') + const prerenderOutputDirectory = join( + serverOutputDirectory, + '.tanstack/prerender', + ) const serverRouteOptionsDirectory = join(serverOutputDirectory, 'server') const close = vi.fn() const prerenderSpy = vi.fn(async ({ handler }: any) => { @@ -179,7 +191,10 @@ describe('prerenderWithVite', () => { const root = await mkdtemp(join(tmpdir(), 'tss-vite-prerender-')) const clientOutputDirectory = join(root, 'client') const serverOutputDirectory = join(root, 'server') - const prerenderOutputDirectory = join(serverOutputDirectory, '.tanstack/prerender') + const prerenderOutputDirectory = join( + serverOutputDirectory, + '.tanstack/prerender', + ) const prerenderSpy = vi.fn() const preview = vi.fn() From 2cba39af7551786ad3e71f0874e8376467ccde40 Mon Sep 17 00:00:00 2001 From: jon Date: Wed, 6 May 2026 13:40:43 +0100 Subject: [PATCH 11/11] fix: address prerender route option edge cases --- .../basic/tests/prerendering.spec.ts | 2 +- e2e/solid-start/basic/rsbuild.config.ts | 2 +- e2e/vue-start/basic/rsbuild.config.ts | 2 +- .../src/tests/prerenderParams.test-d.ts | 1 + packages/start-plugin-core/src/post-build.ts | 4 +-- .../src/prerender-params-runner.ts | 2 +- .../src/prerender-route-options.ts | 3 ++ packages/start-plugin-core/src/prerender.ts | 8 ----- .../src/rsbuild/start-compiler-host.ts | 1 + .../tests/build-sitemap.test.ts | 34 ------------------- .../tests/prerender-params-runner.test.ts | 7 ++-- .../tests/rsbuild-post-build.test.ts | 1 + 12 files changed, 15 insertions(+), 52 deletions(-) diff --git a/e2e/react-start/basic/tests/prerendering.spec.ts b/e2e/react-start/basic/tests/prerendering.spec.ts index a9488aca984..d873d722452 100644 --- a/e2e/react-start/basic/tests/prerendering.spec.ts +++ b/e2e/react-start/basic/tests/prerendering.spec.ts @@ -30,7 +30,7 @@ function outputContainsMarker(dir: string, marker: string) { const filePath = join(dir, String(relativePath)) return ( statSync(filePath).isFile() && - filePath.endsWith('.js') && + (filePath.endsWith('.js') || filePath.endsWith('.mjs')) && readFileSync(filePath, 'utf-8').includes(marker) ) }) diff --git a/e2e/solid-start/basic/rsbuild.config.ts b/e2e/solid-start/basic/rsbuild.config.ts index f3ed85b01d7..cdca4222ea3 100644 --- a/e2e/solid-start/basic/rsbuild.config.ts +++ b/e2e/solid-start/basic/rsbuild.config.ts @@ -18,7 +18,7 @@ const prerenderConfiguration = { '/search-params/default', '/transition', '/users', - ].some((p) => page.path.includes(p)), + ].some((p) => page.path === p || page.path.startsWith(`${p}/`)), maxRedirects: 100, } diff --git a/e2e/vue-start/basic/rsbuild.config.ts b/e2e/vue-start/basic/rsbuild.config.ts index c16648bc419..14af169e517 100644 --- a/e2e/vue-start/basic/rsbuild.config.ts +++ b/e2e/vue-start/basic/rsbuild.config.ts @@ -19,7 +19,7 @@ const prerenderConfiguration = { '/search-params', // search-param routes have dynamic content based on query params '/transition', '/users', - ].some((p) => page.path.includes(p)), + ].some((p) => page.path === p || page.path.startsWith(`${p}/`)), maxRedirects: 100, } diff --git a/packages/start-client-core/src/tests/prerenderParams.test-d.ts b/packages/start-client-core/src/tests/prerenderParams.test-d.ts index b16df68f314..dbee7860d82 100644 --- a/packages/start-client-core/src/tests/prerenderParams.test-d.ts +++ b/packages/start-client-core/src/tests/prerenderParams.test-d.ts @@ -1,4 +1,5 @@ import { expectTypeOf, test } from 'vitest' +import type {} from '../prerenderParams' import type { AnyRoute, FileBaseRouteOptions } from '@tanstack/router-core' type ParentRoute = Omit & { diff --git a/packages/start-plugin-core/src/post-build.ts b/packages/start-plugin-core/src/post-build.ts index ddb2f7f9c81..a9c6898b359 100644 --- a/packages/start-plugin-core/src/post-build.ts +++ b/packages/start-plugin-core/src/post-build.ts @@ -24,7 +24,7 @@ export async function postBuild({ } const spaOnly = Boolean( - startConfig.spa?.enabled && startConfig.prerender.enabled !== true, + startConfig.spa?.enabled && startConfig.prerender?.enabled !== true, ) if (startConfig.spa?.enabled) { @@ -62,7 +62,7 @@ export async function postBuild({ }) } - if (startConfig.prerender.enabled) { + if (startConfig.prerender?.enabled) { await adapter.prerender(startConfig) } diff --git a/packages/start-plugin-core/src/prerender-params-runner.ts b/packages/start-plugin-core/src/prerender-params-runner.ts index 00474ff32a2..c8ac212eb4a 100644 --- a/packages/start-plugin-core/src/prerender-params-runner.ts +++ b/packages/start-plugin-core/src/prerender-params-runner.ts @@ -201,7 +201,7 @@ function createPageFromParams( return { path: interpolatedPath + stringifySearch(entry.search), sitemap: mergeOptions(options.sitemap, entry.sitemap), - prerender: entry.prerender, + prerender: mergeOptions(options.prerender, entry.prerender), } } diff --git a/packages/start-plugin-core/src/prerender-route-options.ts b/packages/start-plugin-core/src/prerender-route-options.ts index f38ffcf3433..456a11fdcc1 100644 --- a/packages/start-plugin-core/src/prerender-route-options.ts +++ b/packages/start-plugin-core/src/prerender-route-options.ts @@ -1,6 +1,7 @@ import type { AnyRoute } from '@tanstack/router-core' import type { PrerenderParamsEntry, + RoutePrerenderOptions, RouteSitemapOptions, } from '@tanstack/start-client-core' @@ -16,6 +17,7 @@ export interface PrerenderRouteOptions { }) => | ReadonlyArray>> | Promise>>> + prerender?: RoutePrerenderOptions sitemap?: RouteSitemapOptions } @@ -61,6 +63,7 @@ export function collectPrerenderRouteOptions(routeTree: AnyRoute | undefined): { if (options.prerenderParams || options.sitemap) { routeOptions.set(routePath, { prerenderParams: options.prerenderParams, + prerender: options.prerender, sitemap: options.sitemap, }) } diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts index 0ee6e52b4ae..a73648b369e 100644 --- a/packages/start-plugin-core/src/prerender.ts +++ b/packages/start-plugin-core/src/prerender.ts @@ -61,14 +61,6 @@ export async function prerender({ startConfig.pages = pages } - const routerBasePath = joinURL('/', startConfig.router.basepath ?? '') - const routerBaseUrl = new URL(routerBasePath, 'http://localhost') - - startConfig.pages = validateAndNormalizePrerenderPages( - startConfig.pages, - routerBaseUrl, - ) - const pages = await prerenderPages({ outputDir: handler.getClientOutputDirectory(), }) diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts index e563b867f78..7b11b5f4d7d 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts @@ -79,6 +79,7 @@ export function registerStartCompilerTransforms( const environments = opts.environments ?? [ { name: RSBUILD_ENVIRONMENT_NAMES.client, type: 'client' }, + { name: RSBUILD_ENVIRONMENT_NAMES.prerender, type: 'server' }, { name: RSBUILD_ENVIRONMENT_NAMES.server, type: 'server' }, ] diff --git a/packages/start-plugin-core/tests/build-sitemap.test.ts b/packages/start-plugin-core/tests/build-sitemap.test.ts index 57f538981a0..afc105ac70f 100644 --- a/packages/start-plugin-core/tests/build-sitemap.test.ts +++ b/packages/start-plugin-core/tests/build-sitemap.test.ts @@ -111,40 +111,6 @@ describe('buildSitemap', () => { expect(sitemap).not.toContain('https://example.com/docs//guide/start') }) - it('uses a host supplied from the environment', () => { - const publicDir = mkdtempSync(join(tmpdir(), 'tanstack-start-sitemap-')) - tempDirs.push(publicDir) - - const previousSiteUrl = process.env.SITE_URL - process.env.SITE_URL = 'https://deploy.example.com' - - try { - buildSitemap({ - publicDir, - startConfig: { - sitemap: { - enabled: true, - host: process.env.SITE_URL, - outputPath: 'sitemap.xml', - }, - pages: [{ path: '/guide/start' }], - } as any, - }) - } finally { - if (previousSiteUrl === undefined) { - delete process.env.SITE_URL - } else { - process.env.SITE_URL = previousSiteUrl - } - } - - const sitemap = readFileSync(join(publicDir, 'sitemap.xml'), 'utf-8') - - expect(sitemap).toContain( - 'https://deploy.example.com/guide/start', - ) - }) - it('skips sitemap generation when pages exist but sitemap config is omitted', () => { const publicDir = mkdtempSync(join(tmpdir(), 'tanstack-start-sitemap-')) tempDirs.push(publicDir) diff --git a/packages/start-plugin-core/tests/prerender-params-runner.test.ts b/packages/start-plugin-core/tests/prerender-params-runner.test.ts index a6da2cd9279..2b0a4175ac3 100644 --- a/packages/start-plugin-core/tests/prerender-params-runner.test.ts +++ b/packages/start-plugin-core/tests/prerender-params-runner.test.ts @@ -24,6 +24,7 @@ describe('runPrerenderParams', () => { const routeTree = createRouteTree({ '/posts/$slug': { sitemap: { priority: 0.7 }, + prerender: { retryDelay: 100, retryCount: 2 }, prerenderParams: () => [ { params: { slug: 'hello-world' }, @@ -44,7 +45,7 @@ describe('runPrerenderParams', () => { { path: '/posts/hello-world', sitemap: { priority: 0.7, lastmod: '2026-05-05' }, - prerender: { retryCount: 1 }, + prerender: { retryDelay: 100, retryCount: 1 }, }, ]) }) @@ -331,9 +332,7 @@ describe('runPrerenderParams', () => { pages: [], logger, }) - const expectation = expect(result).rejects.toThrow( - 'This operation was aborted', - ) + const expectation = expect(result).rejects.toThrow(/operation was aborted/i) process.emit('SIGTERM') await expectation diff --git a/packages/start-plugin-core/tests/rsbuild-post-build.test.ts b/packages/start-plugin-core/tests/rsbuild-post-build.test.ts index 404e8d530ff..406099fcda4 100644 --- a/packages/start-plugin-core/tests/rsbuild-post-build.test.ts +++ b/packages/start-plugin-core/tests/rsbuild-post-build.test.ts @@ -13,6 +13,7 @@ describe('postBuildWithRsbuild', () => { beforeEach(() => { vi.resetModules() delete (globalThis as any).__ROUTE_OPTIONS_LOADED + delete (globalThis as any).TSS_PRERENDER_ROUTE_TREE delete process.env.TSS_PRERENDERING delete process.env.TSS_CLIENT_OUTPUT_DIR })