From 5a48214cb135e6743493138f24bbc0db66315451 Mon Sep 17 00:00:00 2001 From: jon Date: Tue, 5 May 2026 16:22:35 +0100 Subject: [PATCH 01/12] 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 0000000000..4af009b383 --- /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 dfaac8fcef..628291a923 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 5a98af5e6c..07f0daf58f 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 b32796cafc..58fa35aa07 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 0000000000..5593659c82 --- /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 0000000000..3e4094ce8f --- /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 0000000000..96e4236558 --- /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 3cb6db2658..3c3f214757 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 ddcf720a4c..8d7531bb5a 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 18089a33a5..8a2bf0ffae 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 ea04a3167a..5067f95ac5 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 c9fba32156..b9d5d04210 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 0000000000..f0a0f79231 --- /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 0000000000..70b3b4ade8 --- /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 0000000000..1bd2d4060a --- /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 ddcf720a4c..73075a49f1 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 9e26483d04..c8128fb92f 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 e1fc89db16..900ace5860 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 93af798c3c..fc8ff3b53f 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 016a292835..bd680185e8 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 0000000000..6eb138da4b --- /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 0000000000..0f698bd76e --- /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 0000000000..43f56e23a5 --- /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 ddcf720a4c..73075a49f1 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 6fe8d5d194..746ab47996 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 f7835c9859..2162a3e815 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 0000000000..bbb888c256 --- /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 0000000000..b16df68f31 --- /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 3eae17ca02..200aca496d 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 c974e5019e..e6f6f407f1 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 966cdd38cc..336a7751ca 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 0000000000..f50f4a444c --- /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 0000000000..f38ffcf343 --- /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 2fbd9e74b9..b04eefd1d0 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 2590ec9931..7464c28651 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 1f512089fe..36d3228f52 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 e0287858e6..6a08120d37 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 645c550aac..273ae47188 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 b614ae4afa..c1e5d39874 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 5eeb76e8d5..da426a1863 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 e4f06893a7..c9c86b3886 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 0000000000..2f855b3bb2 --- /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 0000000000..a7e9689894 --- /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 0000000000..c48948865d --- /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 e245a13249..e9f9d349b7 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 0000000000..88678e8b52 --- /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 a31a81fde8..24814d801f 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 dd9048293e..e357c26767 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/12] 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 8d7531bb5a..2900c44d19 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 73075a49f1..8c03012260 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 73075a49f1..8c03012260 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 36d3228f52..377d4ab56e 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/12] 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 2900c44d19..90ef4327a5 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 8c03012260..c256c0d600 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 8c03012260..c256c0d600 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 336a7751ca..8864cccb2c 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 f50f4a444c..d78d7930ae 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 763f5e5d05..2edd34b81b 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/12] 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 8864cccb2c..63fa273378 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 b04eefd1d0..16ec53677a 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 da426a1863..a89feb6c64 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 2edd34b81b..df98cef4b2 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/12] 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 f142bb2881..c2edd8660e 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 e6f6f407f1..91a859c018 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 63fa273378..f4f1e9942f 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 0000000000..b07f739eab --- /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 16ec53677a..dd70314cc5 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 5ab3b631f2..cb05aad5d9 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 08d299e507..7e261e6ca5 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 7464c28651..4c1ae0ec62 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 066d5b2867..e563b867f7 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 377d4ab56e..a9dc02e9a7 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 fa10d65f64..4be276b5b0 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 2f7d92a4d4..fadd5e2d40 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 6a08120d37..1840069bbf 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 273ae47188..253f977c7d 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 c1e5d39874..b614ae4afa 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 0000000000..ed04f5fb11 --- /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 5db724ba8d..06ac6eae54 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 915fcd7aca..ef9656fb9e 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 a89feb6c64..f40db29ea1 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 3130ca47c1..18b295543a 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 c9c86b3886..539ac910df 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 df98cef4b2..b6479b611e 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 0000000000..8a27515b30 --- /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 c48948865d..9523a772e5 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 e9f9d349b7..d85191f91e 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 52311daa76..7c07805f50 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 88678e8b52..5a8f73fb07 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 0000000000..f9f03bc4fb --- /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 0000000000..daf2ae1761 --- /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 0000000000..4ec24c42c6 --- /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 24814d801f..e904a37492 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/12] 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 628291a923..cbca449c05 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 07f0daf58f..df9d78cef0 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 5067f95ac5..f3ed85b01d 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 fc8ff3b53f..c16648bc41 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 f4f1e9942f..ddb2f7f9c8 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 d78d7930ae..959d27ca0a 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 b07f739eab..9090644e07 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 dd70314cc5..0ee6e52b4a 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 7e261e6ca5..26957ac5cc 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 4c1ae0ec62..0128190aa2 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 a9dc02e9a7..2e81a3b010 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 fadd5e2d40..2ec6839b98 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 06ac6eae54..c0450edd2b 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 ef9656fb9e..c653b3c911 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 f40db29ea1..f039057e12 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 539ac910df..dd19d72576 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 a7e9689894..a6da2cd927 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 8a27515b30..c7c2e91a88 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 d85191f91e..18c8885dc5 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 4ec24c42c6..8b83e967b3 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/12] 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 96e4236558..a5f9a31a2a 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 90ef4327a5..a9488aca98 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/12] 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 9090644e07..51ae3a8ed1 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 ed04f5fb11..0000000000 --- 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 c653b3c911..a8b3b62c15 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 c7c2e91a88..939efd47dc 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 f9f03bc4fb..0000000000 --- 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 daf2ae1761..d134b9958d 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/12] 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 cbca449c05..97f3c5c882 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 df9d78cef0..6f05eeb0b4 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 2f855b3bb2..57f538981a 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/12] 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 959d27ca0a..00474ff32a 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 0aebabada4..91abf93aad 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 0128190aa2..6316e75a50 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 2ec6839b98..5abee50bc2 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 c0450edd2b..11c9eb419e 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 18b295543a..3e844fad89 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 9523a772e5..7d365b8fc9 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 7c07805f50..404e8d530f 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 8b83e967b3..e97a321f6e 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/12] 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 a9488aca98..d873d72245 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 f3ed85b01d..cdca4222ea 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 c16648bc41..14af169e51 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 b16df68f31..dbee7860d8 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 ddb2f7f9c8..a9c6898b35 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 00474ff32a..c8ac212eb4 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 f38ffcf343..456a11fdcc 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 0ee6e52b4a..a73648b369 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 e563b867f7..7b11b5f4d7 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 57f538981a..afc105ac70 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 a6da2cd927..2b0a4175ac 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 404e8d530f..406099fcda 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 }) From 25d47c42a58d9bd0c639e9bb3132ea4726670367 Mon Sep 17 00:00:00 2001 From: jon Date: Fri, 15 May 2026 09:06:14 +0100 Subject: [PATCH 12/12] test: expand prerender params coverage and refactor sitemap build --- .../basic-file-based/src/routeTree.gen.ts | 144 ++++---- .../route.tsx" | 2 +- .../wildcard.$.tsx" | 2 +- .../\360\237\232\200.$id.tsx" | 2 +- .../src/routes/prerender-params.$slug.tsx | 40 +-- .../basic/tests/prerendering.spec.ts | 2 +- .../server-routes/src/routeTree.gen.ts | 98 +++--- .../basic-file-based/src/routeTree.gen.ts | 144 ++++---- .../route.tsx" | 2 +- .../wildcard.$.tsx" | 2 +- .../\360\237\232\200.$id.tsx" | 2 +- .../src/routes/prerender-params.$slug.tsx | 24 +- .../basic/tests/prerendering.spec.ts | 2 +- .../basic-file-based-sfc/src/routeTree.gen.ts | 41 ++- .../src/routes/prerender-params.$slug.tsx | 24 +- .../basic/tests/prerendering.spec.ts | 2 +- .../basic-file-based-sfc/src/routeTree.gen.ts | 41 ++- .../src/core/code-splitter/compilers.ts | 2 +- packages/router-plugin/src/core/utils.ts | 2 +- .../router-plugin/tests/delete-nodes.test.ts | 81 +++++ .../start-client-core/src/prerenderParams.ts | 7 +- .../src/tests/prerenderParams.test-d.ts | 42 +++ packages/start-plugin-core/package.json | 1 + .../start-plugin-core/src/build-sitemap.ts | 307 ++++++------------ packages/start-plugin-core/src/post-build.ts | 46 ++- .../src/prerender-params-runner.ts | 306 ++++++++--------- .../src/prerender-route-options.ts | 7 +- packages/start-plugin-core/src/prerender.ts | 267 +++++++-------- .../src/rsbuild/post-build.ts | 3 +- .../src/vite/post-server-build.ts | 3 +- .../start-plugin-core/src/vite/prerender.ts | 5 +- .../tests/build-sitemap.test.ts | 227 +++++-------- .../tests/post-server-build.test.ts | 30 +- .../tests/prerender-params-runner.bench.ts | 162 +++++++++ .../tests/prerender-params-runner.test.ts | 267 +++++++++++++-- .../tests/prerender-ssrf.test.ts | 20 +- .../tests/rsbuild-post-build.test.ts | 2 +- 37 files changed, 1431 insertions(+), 930 deletions(-) create mode 100644 packages/start-plugin-core/tests/prerender-params-runner.bench.ts diff --git a/e2e/react-router/basic-file-based/src/routeTree.gen.ts b/e2e/react-router/basic-file-based/src/routeTree.gen.ts index daf304c064..08b4566758 100644 --- a/e2e/react-router/basic-file-based/src/routeTree.gen.ts +++ b/e2e/react-router/basic-file-based/src/routeTree.gen.ts @@ -20,7 +20,7 @@ import { Route as EditingARouteImport } from './routes/editing-a' import { Route as ComponentTypesTestRouteImport } from './routes/component-types-test' import { Route as AnchorRouteImport } from './routes/anchor' import { Route as LayoutRouteImport } from './routes/_layout' -import { Route as Char45824Char54620Char48124Char44397RouteRouteImport } from './routes/대한민국/route' +import { Route as Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteImport } from './routes/대한민국/route' import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route' import { Route as PathlessLayoutRouteRouteImport } from './routes/pathless-layout/route' import { Route as NonNestedRouteRouteImport } from './routes/non-nested/route' @@ -56,8 +56,8 @@ import { Route as PathlessLayoutLayoutIndexRouteImport } from './routes/pathless import { Route as ParamsPsWildcardIndexRouteImport } from './routes/params-ps/wildcard/index' import { Route as ParamsPsNamedIndexRouteImport } from './routes/params-ps/named/index' import { Route as FullpathTestLayoutIndexRouteImport } from './routes/fullpath-test/_layout/index' -import { Route as Char45824Char54620Char48124Char44397Char55357Char56960IdRouteImport } from './routes/대한민국/🚀.$id' -import { Route as Char45824Char54620Char48124Char44397WildcardSplatRouteImport } from './routes/대한민국/wildcard.$' +import { Route as Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRouteImport } from './routes/대한민국/🚀.$id' +import { Route as Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRouteImport } from './routes/대한민국/wildcard.$' import { Route as RelativeUseNavigateRelativeUseNavigateBRouteImport } from './routes/relative/useNavigate/relative-useNavigate-b' import { Route as RelativeUseNavigateRelativeUseNavigateARouteImport } from './routes/relative/useNavigate/relative-useNavigate-a' import { Route as RelativeLinkRelativeLinkBRouteImport } from './routes/relative/link/relative-link-b' @@ -184,12 +184,14 @@ const LayoutRoute = LayoutRouteImport.update({ id: '/_layout', getParentRoute: () => rootRouteImport, } as any) -const Char45824Char54620Char48124Char44397RouteRoute = - Char45824Char54620Char48124Char44397RouteRouteImport.update({ - id: '/대한민국', - path: '/대한민국', - getParentRoute: () => rootRouteImport, - } as any) +const Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRoute = + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteImport.update( + { + id: '/대한민국', + path: '/대한민국', + getParentRoute: () => rootRouteImport, + } as any, + ) const SearchParamsRouteRoute = SearchParamsRouteRouteImport.update({ id: '/search-params', path: '/search-params', @@ -369,18 +371,24 @@ const FullpathTestLayoutIndexRoute = FullpathTestLayoutIndexRouteImport.update({ path: '/', getParentRoute: () => FullpathTestLayoutRouteRoute, } as any) -const Char45824Char54620Char48124Char44397Char55357Char56960IdRoute = - Char45824Char54620Char48124Char44397Char55357Char56960IdRouteImport.update({ - id: '/🚀/$id', - path: '/🚀/$id', - getParentRoute: () => Char45824Char54620Char48124Char44397RouteRoute, - } as any) -const Char45824Char54620Char48124Char44397WildcardSplatRoute = - Char45824Char54620Char48124Char44397WildcardSplatRouteImport.update({ - id: '/wildcard/$', - path: '/wildcard/$', - getParentRoute: () => Char45824Char54620Char48124Char44397RouteRoute, - } as any) +const Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRoute = + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRouteImport.update( + { + id: '/🚀/$id', + path: '/🚀/$id', + getParentRoute: () => + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRoute, + } as any, + ) +const Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRoute = + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRouteImport.update( + { + id: '/wildcard/$', + path: '/wildcard/$', + getParentRoute: () => + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRoute, + } as any, + ) const RelativeUseNavigateRelativeUseNavigateBRoute = RelativeUseNavigateRelativeUseNavigateBRouteImport.update({ id: '/relative-useNavigate-b', @@ -784,7 +792,7 @@ export interface FileRoutesByFullPath { '/non-nested': typeof NonNestedRouteRouteWithChildren '/pathless-layout': typeof PathlessLayoutLayoutRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren - '/대한민국': typeof Char45824Char54620Char48124Char44397RouteRouteWithChildren + '/대한민국': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteWithChildren '/anchor': typeof AnchorRoute '/component-types-test': typeof ComponentTypesTestRoute '/editing-a': typeof EditingARoute @@ -855,8 +863,8 @@ export interface FileRoutesByFullPath { '/relative/link/relative-link-b': typeof RelativeLinkRelativeLinkBRoute '/relative/useNavigate/relative-useNavigate-a': typeof RelativeUseNavigateRelativeUseNavigateARoute '/relative/useNavigate/relative-useNavigate-b': typeof RelativeUseNavigateRelativeUseNavigateBRoute - '/대한민국/wildcard/$': typeof Char45824Char54620Char48124Char44397WildcardSplatRoute - '/대한민국/🚀/$id': typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRoute + '/대한민국/wildcard/$': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRoute + '/대한민국/🚀/$id': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRoute '/fullpath-test/': typeof FullpathTestLayoutIndexRoute '/params-ps/named/': typeof ParamsPsNamedIndexRoute '/params-ps/wildcard/': typeof ParamsPsWildcardIndexRoute @@ -900,7 +908,7 @@ export interface FileRoutesByTo { '/fullpath-test': typeof FullpathTestLayoutIndexRoute '/non-nested': typeof NonNestedRouteRouteWithChildren '/pathless-layout': typeof PathlessLayoutLayoutIndexRoute - '/대한민국': typeof Char45824Char54620Char48124Char44397RouteRouteWithChildren + '/대한민국': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteWithChildren '/anchor': typeof AnchorRoute '/component-types-test': typeof ComponentTypesTestRoute '/editing-a': typeof EditingARoute @@ -964,8 +972,8 @@ export interface FileRoutesByTo { '/relative/link/relative-link-b': typeof RelativeLinkRelativeLinkBRoute '/relative/useNavigate/relative-useNavigate-a': typeof RelativeUseNavigateRelativeUseNavigateARoute '/relative/useNavigate/relative-useNavigate-b': typeof RelativeUseNavigateRelativeUseNavigateBRoute - '/대한민국/wildcard/$': typeof Char45824Char54620Char48124Char44397WildcardSplatRoute - '/대한민국/🚀/$id': typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRoute + '/대한민국/wildcard/$': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRoute + '/대한민국/🚀/$id': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRoute '/params-ps/named': typeof ParamsPsNamedIndexRoute '/params-ps/wildcard': typeof ParamsPsWildcardIndexRoute '/redirect/$target': typeof RedirectTargetIndexRoute @@ -1007,7 +1015,7 @@ export interface FileRoutesById { '/non-nested': typeof NonNestedRouteRouteWithChildren '/pathless-layout': typeof PathlessLayoutRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren - '/대한민국': typeof Char45824Char54620Char48124Char44397RouteRouteWithChildren + '/대한민국': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteWithChildren '/_layout': typeof LayoutRouteWithChildren '/anchor': typeof AnchorRoute '/component-types-test': typeof ComponentTypesTestRoute @@ -1083,8 +1091,8 @@ export interface FileRoutesById { '/relative/link/relative-link-b': typeof RelativeLinkRelativeLinkBRoute '/relative/useNavigate/relative-useNavigate-a': typeof RelativeUseNavigateRelativeUseNavigateARoute '/relative/useNavigate/relative-useNavigate-b': typeof RelativeUseNavigateRelativeUseNavigateBRoute - '/대한민국/wildcard/$': typeof Char45824Char54620Char48124Char44397WildcardSplatRoute - '/대한민국/🚀/$id': typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRoute + '/대한민국/wildcard/$': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRoute + '/대한민국/🚀/$id': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRoute '/fullpath-test/_layout/': typeof FullpathTestLayoutIndexRoute '/params-ps/named/': typeof ParamsPsNamedIndexRoute '/params-ps/wildcard/': typeof ParamsPsWildcardIndexRoute @@ -1131,7 +1139,7 @@ export interface FileRouteTypes { | '/non-nested' | '/pathless-layout' | '/search-params' - | '/대한민국' + | '/대한민국' | '/anchor' | '/component-types-test' | '/editing-a' @@ -1202,8 +1210,8 @@ export interface FileRouteTypes { | '/relative/link/relative-link-b' | '/relative/useNavigate/relative-useNavigate-a' | '/relative/useNavigate/relative-useNavigate-b' - | '/대한민국/wildcard/$' - | '/대한민국/🚀/$id' + | '/대한민국/wildcard/$' + | '/대한민국/🚀/$id' | '/fullpath-test/' | '/params-ps/named/' | '/params-ps/wildcard/' @@ -1247,7 +1255,7 @@ export interface FileRouteTypes { | '/fullpath-test' | '/non-nested' | '/pathless-layout' - | '/대한민국' + | '/대한민국' | '/anchor' | '/component-types-test' | '/editing-a' @@ -1311,8 +1319,8 @@ export interface FileRouteTypes { | '/relative/link/relative-link-b' | '/relative/useNavigate/relative-useNavigate-a' | '/relative/useNavigate/relative-useNavigate-b' - | '/대한민국/wildcard/$' - | '/대한민국/🚀/$id' + | '/대한민국/wildcard/$' + | '/대한민국/🚀/$id' | '/params-ps/named' | '/params-ps/wildcard' | '/redirect/$target' @@ -1353,7 +1361,7 @@ export interface FileRouteTypes { | '/non-nested' | '/pathless-layout' | '/search-params' - | '/대한민국' + | '/대한민국' | '/_layout' | '/anchor' | '/component-types-test' @@ -1429,8 +1437,8 @@ export interface FileRouteTypes { | '/relative/link/relative-link-b' | '/relative/useNavigate/relative-useNavigate-a' | '/relative/useNavigate/relative-useNavigate-b' - | '/대한민국/wildcard/$' - | '/대한민국/🚀/$id' + | '/대한민국/wildcard/$' + | '/대한민국/🚀/$id' | '/fullpath-test/_layout/' | '/params-ps/named/' | '/params-ps/wildcard/' @@ -1476,7 +1484,7 @@ export interface RootRouteChildren { NonNestedRouteRoute: typeof NonNestedRouteRouteWithChildren PathlessLayoutRouteRoute: typeof PathlessLayoutRouteRouteWithChildren SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren - Char45824Char54620Char48124Char44397RouteRoute: typeof Char45824Char54620Char48124Char44397RouteRouteWithChildren + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteWithChildren LayoutRoute: typeof LayoutRouteWithChildren AnchorRoute: typeof AnchorRoute ComponentTypesTestRoute: typeof ComponentTypesTestRoute @@ -1598,11 +1606,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } - '/대한민국': { - id: '/대한민국' - path: '/대한민국' - fullPath: '/대한민국' - preLoaderRoute: typeof Char45824Char54620Char48124Char44397RouteRouteImport + '/대한민국': { + id: '/대한민국' + path: '/대한민국' + fullPath: '/대한민국' + preLoaderRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteImport parentRoute: typeof rootRouteImport } '/search-params': { @@ -1850,19 +1858,19 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof FullpathTestLayoutIndexRouteImport parentRoute: typeof FullpathTestLayoutRouteRoute } - '/대한민국/🚀/$id': { - id: '/대한민국/🚀/$id' + '/대한민국/🚀/$id': { + id: '/대한민국/🚀/$id' path: '/🚀/$id' - fullPath: '/대한민국/🚀/$id' - preLoaderRoute: typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRouteImport - parentRoute: typeof Char45824Char54620Char48124Char44397RouteRoute + fullPath: '/대한민국/🚀/$id' + preLoaderRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRouteImport + parentRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRoute } - '/대한민국/wildcard/$': { - id: '/대한민국/wildcard/$' + '/대한민국/wildcard/$': { + id: '/대한민국/wildcard/$' path: '/wildcard/$' - fullPath: '/대한민국/wildcard/$' - preLoaderRoute: typeof Char45824Char54620Char48124Char44397WildcardSplatRouteImport - parentRoute: typeof Char45824Char54620Char48124Char44397RouteRoute + fullPath: '/대한민국/wildcard/$' + preLoaderRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRouteImport + parentRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRoute } '/relative/useNavigate/relative-useNavigate-b': { id: '/relative/useNavigate/relative-useNavigate-b' @@ -2635,22 +2643,22 @@ const SearchParamsRouteRouteChildren: SearchParamsRouteRouteChildren = { const SearchParamsRouteRouteWithChildren = SearchParamsRouteRoute._addFileChildren(SearchParamsRouteRouteChildren) -interface Char45824Char54620Char48124Char44397RouteRouteChildren { - Char45824Char54620Char48124Char44397WildcardSplatRoute: typeof Char45824Char54620Char48124Char44397WildcardSplatRoute - Char45824Char54620Char48124Char44397Char55357Char56960IdRoute: typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRoute +interface Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteChildren { + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRoute + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRoute } -const Char45824Char54620Char48124Char44397RouteRouteChildren: Char45824Char54620Char48124Char44397RouteRouteChildren = +const Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteChildren: Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteChildren = { - Char45824Char54620Char48124Char44397WildcardSplatRoute: - Char45824Char54620Char48124Char44397WildcardSplatRoute, - Char45824Char54620Char48124Char44397Char55357Char56960IdRoute: - Char45824Char54620Char48124Char44397Char55357Char56960IdRoute, + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRoute: + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRoute, + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRoute: + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRoute, } -const Char45824Char54620Char48124Char44397RouteRouteWithChildren = - Char45824Char54620Char48124Char44397RouteRoute._addFileChildren( - Char45824Char54620Char48124Char44397RouteRouteChildren, +const Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteWithChildren = + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRoute._addFileChildren( + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteChildren, ) interface LayoutLayout2RouteChildren { @@ -2873,8 +2881,8 @@ const rootRouteChildren: RootRouteChildren = { NonNestedRouteRoute: NonNestedRouteRouteWithChildren, PathlessLayoutRouteRoute: PathlessLayoutRouteRouteWithChildren, SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren, - Char45824Char54620Char48124Char44397RouteRoute: - Char45824Char54620Char48124Char44397RouteRouteWithChildren, + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRoute: + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteWithChildren, LayoutRoute: LayoutRouteWithChildren, AnchorRoute: AnchorRoute, ComponentTypesTestRoute: ComponentTypesTestRoute, diff --git "a/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/route.tsx" "b/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/route.tsx" index cff4d82723..30c638c41b 100644 --- "a/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/route.tsx" +++ "b/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/route.tsx" @@ -1,6 +1,6 @@ import { Link, Outlet, createFileRoute } from '@tanstack/react-router' -export const Route = createFileRoute('/대한민국')({ +export const Route = createFileRoute('/대한민국')({ component: RouteComponent, }) diff --git "a/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/wildcard.$.tsx" "b/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/wildcard.$.tsx" index 5a0770344b..d43a302afd 100644 --- "a/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/wildcard.$.tsx" +++ "b/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/wildcard.$.tsx" @@ -1,6 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' -export const Route = createFileRoute('/대한민국/wildcard/$')({ +export const Route = createFileRoute('/대한민국/wildcard/$')({ component: RouteComponent, }) diff --git "a/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/\360\237\232\200.$id.tsx" "b/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/\360\237\232\200.$id.tsx" index c822248670..e3d4efba07 100644 --- "a/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/\360\237\232\200.$id.tsx" +++ "b/e2e/react-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/\360\237\232\200.$id.tsx" @@ -1,6 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' -export const Route = createFileRoute('/대한민국/🚀/$id')({ +export const Route = createFileRoute('/대한민국/🚀/$id')({ component: RouteComponent, }) 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 a5f9a31a2a..2d28e85ec5 100644 --- a/e2e/react-start/basic/src/routes/prerender-params.$slug.tsx +++ b/e2e/react-start/basic/src/routes/prerender-params.$slug.tsx @@ -29,64 +29,64 @@ export const Route = createFileRoute('/prerender-params/$slug')({ sitemap: { changefreq: 'weekly', }, - prerenderParams: () => [ - { + *prerenderParams() { + yield { params: { slug: 'hello-world' }, sitemap: { lastmod: '2026-05-05', priority: 0.8, }, - }, - { + } + yield { params: { slug: '대한민국' }, sitemap: { priority: 0.6, }, - }, - { + } + yield { params: { slug: 'reserved?hash#plus+' }, sitemap: { exclude: true, }, - }, - { + } + yield { params: { slug: 'with-query' }, search: { page: 2, tag: 'router start' }, sitemap: { priority: 0.4, }, - }, - { + } + yield { params: { slug: getServerOnlyPrerenderSlug() }, sitemap: { exclude: true, }, - }, - { + } + yield { params: { slug: topLevelPrerenderLiteral }, sitemap: { exclude: true, }, - }, - { + } + yield { params: { slug: topLevelPrerenderImportedMarker }, sitemap: { exclude: true, }, - }, - { + } + yield { params: { slug: topLevelPrerenderImportedCall }, sitemap: { exclude: true, }, - }, - { + } + yield { 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 d873d72245..0c082e1292 100644 --- a/e2e/react-start/basic/tests/prerendering.spec.ts +++ b/e2e/react-start/basic/tests/prerendering.spec.ts @@ -99,7 +99,7 @@ test.describe('Prerender Static Path Discovery', () => { expect(html).toContain('Nested prerendered slug: under-layout') }) - test('should contain prerendered content from route prerenderParams', () => { + test('should contain prerendered content from yielded route prerenderParams', () => { const htmlPath = join(distDir, 'prerender-params/hello-world/index.html') expect(existsSync(htmlPath)).toBe(true) diff --git a/e2e/react-start/server-routes/src/routeTree.gen.ts b/e2e/react-start/server-routes/src/routeTree.gen.ts index 1c32040cef..2b8bac6e09 100644 --- a/e2e/react-start/server-routes/src/routeTree.gen.ts +++ b/e2e/react-start/server-routes/src/routeTree.gen.ts @@ -16,9 +16,9 @@ import { Route as MethodsIndexRouteImport } from './routes/methods/index' import { Route as MethodsOnlyAnyRouteImport } from './routes/methods/only-any' import { Route as ApiOnlyAnyRouteImport } from './routes/api/only-any' import { Route as ApiMiddlewareContextRouteImport } from './routes/api/middleware-context' +import { Route as ApiHeadRedirectFallbackRouteImport } from './routes/api/head-redirect-fallback' import { Route as ApiHeadFallbackRouteImport } from './routes/api/head-fallback' import { Route as ApiGetAndAnyRouteImport } from './routes/api/get-and-any' -import { Route as ApiHeadRedirectFallbackRouteImport } from './routes/api/head-redirect-fallback' import { Route as ApiParamsFooRouteRouteImport } from './routes/api/params/$foo/route' import { Route as ApiParamsFooBarRouteImport } from './routes/api/params/$foo/$bar' @@ -52,6 +52,16 @@ const ApiOnlyAnyRoute = ApiOnlyAnyRouteImport.update({ path: '/api/only-any', getParentRoute: () => rootRouteImport, } as any) +const ApiMiddlewareContextRoute = ApiMiddlewareContextRouteImport.update({ + id: '/api/middleware-context', + path: '/api/middleware-context', + getParentRoute: () => rootRouteImport, +} as any) +const ApiHeadRedirectFallbackRoute = ApiHeadRedirectFallbackRouteImport.update({ + id: '/api/head-redirect-fallback', + path: '/api/head-redirect-fallback', + getParentRoute: () => rootRouteImport, +} as any) const ApiHeadFallbackRoute = ApiHeadFallbackRouteImport.update({ id: '/api/head-fallback', path: '/api/head-fallback', @@ -62,16 +72,6 @@ const ApiGetAndAnyRoute = ApiGetAndAnyRouteImport.update({ path: '/api/get-and-any', getParentRoute: () => rootRouteImport, } as any) -const ApiHeadRedirectFallbackRoute = ApiHeadRedirectFallbackRouteImport.update({ - id: '/api/head-redirect-fallback', - path: '/api/head-redirect-fallback', - getParentRoute: () => rootRouteImport, -} as any) -const ApiMiddlewareContextRoute = ApiMiddlewareContextRouteImport.update({ - id: '/api/middleware-context', - path: '/api/middleware-context', - getParentRoute: () => rootRouteImport, -} as any) const ApiParamsFooRouteRoute = ApiParamsFooRouteRouteImport.update({ id: '/api/params/$foo', path: '/api/params/$foo', @@ -87,11 +87,11 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/methods': typeof MethodsRouteRouteWithChildren '/merge-middleware-context': typeof MergeMiddlewareContextRoute - '/api/middleware-context': typeof ApiMiddlewareContextRoute - '/api/only-any': typeof ApiOnlyAnyRoute - '/api/head-fallback': typeof ApiHeadFallbackRoute '/api/get-and-any': typeof ApiGetAndAnyRoute + '/api/head-fallback': typeof ApiHeadFallbackRoute '/api/head-redirect-fallback': typeof ApiHeadRedirectFallbackRoute + '/api/middleware-context': typeof ApiMiddlewareContextRoute + '/api/only-any': typeof ApiOnlyAnyRoute '/methods/only-any': typeof MethodsOnlyAnyRoute '/methods/': typeof MethodsIndexRoute '/api/params/$foo': typeof ApiParamsFooRouteRouteWithChildren @@ -100,11 +100,11 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/merge-middleware-context': typeof MergeMiddlewareContextRoute - '/api/middleware-context': typeof ApiMiddlewareContextRoute - '/api/only-any': typeof ApiOnlyAnyRoute - '/api/head-fallback': typeof ApiHeadFallbackRoute '/api/get-and-any': typeof ApiGetAndAnyRoute + '/api/head-fallback': typeof ApiHeadFallbackRoute '/api/head-redirect-fallback': typeof ApiHeadRedirectFallbackRoute + '/api/middleware-context': typeof ApiMiddlewareContextRoute + '/api/only-any': typeof ApiOnlyAnyRoute '/methods/only-any': typeof MethodsOnlyAnyRoute '/methods': typeof MethodsIndexRoute '/api/params/$foo': typeof ApiParamsFooRouteRouteWithChildren @@ -115,11 +115,11 @@ export interface FileRoutesById { '/': typeof IndexRoute '/methods': typeof MethodsRouteRouteWithChildren '/merge-middleware-context': typeof MergeMiddlewareContextRoute - '/api/middleware-context': typeof ApiMiddlewareContextRoute - '/api/only-any': typeof ApiOnlyAnyRoute - '/api/head-fallback': typeof ApiHeadFallbackRoute '/api/get-and-any': typeof ApiGetAndAnyRoute + '/api/head-fallback': typeof ApiHeadFallbackRoute '/api/head-redirect-fallback': typeof ApiHeadRedirectFallbackRoute + '/api/middleware-context': typeof ApiMiddlewareContextRoute + '/api/only-any': typeof ApiOnlyAnyRoute '/methods/only-any': typeof MethodsOnlyAnyRoute '/methods/': typeof MethodsIndexRoute '/api/params/$foo': typeof ApiParamsFooRouteRouteWithChildren @@ -131,11 +131,11 @@ export interface FileRouteTypes { | '/' | '/methods' | '/merge-middleware-context' - | '/api/middleware-context' - | '/api/only-any' - | '/api/head-fallback' | '/api/get-and-any' + | '/api/head-fallback' | '/api/head-redirect-fallback' + | '/api/middleware-context' + | '/api/only-any' | '/methods/only-any' | '/methods/' | '/api/params/$foo' @@ -144,11 +144,11 @@ export interface FileRouteTypes { to: | '/' | '/merge-middleware-context' - | '/api/middleware-context' - | '/api/only-any' - | '/api/head-fallback' | '/api/get-and-any' + | '/api/head-fallback' | '/api/head-redirect-fallback' + | '/api/middleware-context' + | '/api/only-any' | '/methods/only-any' | '/methods' | '/api/params/$foo' @@ -158,11 +158,11 @@ export interface FileRouteTypes { | '/' | '/methods' | '/merge-middleware-context' - | '/api/middleware-context' - | '/api/only-any' - | '/api/head-fallback' | '/api/get-and-any' + | '/api/head-fallback' | '/api/head-redirect-fallback' + | '/api/middleware-context' + | '/api/only-any' | '/methods/only-any' | '/methods/' | '/api/params/$foo' @@ -173,11 +173,11 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute MethodsRouteRoute: typeof MethodsRouteRouteWithChildren MergeMiddlewareContextRoute: typeof MergeMiddlewareContextRoute - ApiMiddlewareContextRoute: typeof ApiMiddlewareContextRoute - ApiOnlyAnyRoute: typeof ApiOnlyAnyRoute - ApiHeadFallbackRoute: typeof ApiHeadFallbackRoute ApiGetAndAnyRoute: typeof ApiGetAndAnyRoute + ApiHeadFallbackRoute: typeof ApiHeadFallbackRoute ApiHeadRedirectFallbackRoute: typeof ApiHeadRedirectFallbackRoute + ApiMiddlewareContextRoute: typeof ApiMiddlewareContextRoute + ApiOnlyAnyRoute: typeof ApiOnlyAnyRoute ApiParamsFooRouteRoute: typeof ApiParamsFooRouteRouteWithChildren } @@ -225,6 +225,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiOnlyAnyRouteImport parentRoute: typeof rootRouteImport } + '/api/middleware-context': { + id: '/api/middleware-context' + path: '/api/middleware-context' + fullPath: '/api/middleware-context' + preLoaderRoute: typeof ApiMiddlewareContextRouteImport + parentRoute: typeof rootRouteImport + } + '/api/head-redirect-fallback': { + id: '/api/head-redirect-fallback' + path: '/api/head-redirect-fallback' + fullPath: '/api/head-redirect-fallback' + preLoaderRoute: typeof ApiHeadRedirectFallbackRouteImport + parentRoute: typeof rootRouteImport + } '/api/head-fallback': { id: '/api/head-fallback' path: '/api/head-fallback' @@ -239,20 +253,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiGetAndAnyRouteImport parentRoute: typeof rootRouteImport } - '/api/head-redirect-fallback': { - id: '/api/head-redirect-fallback' - path: '/api/head-redirect-fallback' - fullPath: '/api/head-redirect-fallback' - preLoaderRoute: typeof ApiHeadRedirectFallbackRouteImport - parentRoute: typeof rootRouteImport - } - '/api/middleware-context': { - id: '/api/middleware-context' - path: '/api/middleware-context' - fullPath: '/api/middleware-context' - preLoaderRoute: typeof ApiMiddlewareContextRouteImport - parentRoute: typeof rootRouteImport - } '/api/params/$foo': { id: '/api/params/$foo' path: '/api/params/$foo' @@ -299,11 +299,11 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, MethodsRouteRoute: MethodsRouteRouteWithChildren, MergeMiddlewareContextRoute: MergeMiddlewareContextRoute, - ApiMiddlewareContextRoute: ApiMiddlewareContextRoute, - ApiOnlyAnyRoute: ApiOnlyAnyRoute, - ApiHeadFallbackRoute: ApiHeadFallbackRoute, ApiGetAndAnyRoute: ApiGetAndAnyRoute, + ApiHeadFallbackRoute: ApiHeadFallbackRoute, ApiHeadRedirectFallbackRoute: ApiHeadRedirectFallbackRoute, + ApiMiddlewareContextRoute: ApiMiddlewareContextRoute, + ApiOnlyAnyRoute: ApiOnlyAnyRoute, ApiParamsFooRouteRoute: ApiParamsFooRouteRouteWithChildren, } export const routeTree = rootRouteImport diff --git a/e2e/solid-router/basic-file-based/src/routeTree.gen.ts b/e2e/solid-router/basic-file-based/src/routeTree.gen.ts index 04fe587374..1d01173fe2 100644 --- a/e2e/solid-router/basic-file-based/src/routeTree.gen.ts +++ b/e2e/solid-router/basic-file-based/src/routeTree.gen.ts @@ -19,7 +19,7 @@ import { Route as EditingARouteImport } from './routes/editing-a' import { Route as ComponentTypesTestRouteImport } from './routes/component-types-test' import { Route as AnchorRouteImport } from './routes/anchor' import { Route as LayoutRouteImport } from './routes/_layout' -import { Route as Char45824Char54620Char48124Char44397RouteRouteImport } from './routes/대한민국/route' +import { Route as Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteImport } from './routes/대한민국/route' import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route' import { Route as NonNestedRouteRouteImport } from './routes/non-nested/route' import { Route as IndexRouteImport } from './routes/index' @@ -47,8 +47,8 @@ import { Route as NonNestedDeepRouteRouteImport } from './routes/non-nested/deep import { Route as RedirectTargetIndexRouteImport } from './routes/redirect/$target/index' import { Route as ParamsPsWildcardIndexRouteImport } from './routes/params-ps/wildcard/index' import { Route as ParamsPsNamedIndexRouteImport } from './routes/params-ps/named/index' -import { Route as Char45824Char54620Char48124Char44397Char55357Char56960IdRouteImport } from './routes/대한민국/🚀.$id' -import { Route as Char45824Char54620Char48124Char44397WildcardSplatRouteImport } from './routes/대한민국/wildcard.$' +import { Route as Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRouteImport } from './routes/대한민국/🚀.$id' +import { Route as Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRouteImport } from './routes/대한민국/wildcard.$' import { Route as TransitionTypingCreateResourceRouteImport } from './routes/transition/typing/create-resource' import { Route as TransitionCountCreateResourceRouteImport } from './routes/transition/count/create-resource' import { Route as RelativeUseNavigateRelativeUseNavigateBRouteImport } from './routes/relative/useNavigate/relative-useNavigate-b' @@ -164,12 +164,14 @@ const LayoutRoute = LayoutRouteImport.update({ id: '/_layout', getParentRoute: () => rootRouteImport, } as any) -const Char45824Char54620Char48124Char44397RouteRoute = - Char45824Char54620Char48124Char44397RouteRouteImport.update({ - id: '/대한민국', - path: '/대한민국', - getParentRoute: () => rootRouteImport, - } as any) +const Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRoute = + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteImport.update( + { + id: '/대한민국', + path: '/대한민국', + getParentRoute: () => rootRouteImport, + } as any, + ) const SearchParamsRouteRoute = SearchParamsRouteRouteImport.update({ id: '/search-params', path: '/search-params', @@ -307,18 +309,24 @@ const ParamsPsNamedIndexRoute = ParamsPsNamedIndexRouteImport.update({ path: '/params-ps/named/', getParentRoute: () => rootRouteImport, } as any) -const Char45824Char54620Char48124Char44397Char55357Char56960IdRoute = - Char45824Char54620Char48124Char44397Char55357Char56960IdRouteImport.update({ - id: '/🚀/$id', - path: '/🚀/$id', - getParentRoute: () => Char45824Char54620Char48124Char44397RouteRoute, - } as any) -const Char45824Char54620Char48124Char44397WildcardSplatRoute = - Char45824Char54620Char48124Char44397WildcardSplatRouteImport.update({ - id: '/wildcard/$', - path: '/wildcard/$', - getParentRoute: () => Char45824Char54620Char48124Char44397RouteRoute, - } as any) +const Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRoute = + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRouteImport.update( + { + id: '/🚀/$id', + path: '/🚀/$id', + getParentRoute: () => + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRoute, + } as any, + ) +const Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRoute = + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRouteImport.update( + { + id: '/wildcard/$', + path: '/wildcard/$', + getParentRoute: () => + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRoute, + } as any, + ) const TransitionTypingCreateResourceRoute = TransitionTypingCreateResourceRouteImport.update({ id: '/transition/typing/create-resource', @@ -685,7 +693,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/non-nested': typeof NonNestedRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren - '/대한민국': typeof Char45824Char54620Char48124Char44397RouteRouteWithChildren + '/대한민국': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteWithChildren '/anchor': typeof AnchorRoute '/component-types-test': typeof ComponentTypesTestRoute '/editing-a': typeof EditingARoute @@ -747,8 +755,8 @@ export interface FileRoutesByFullPath { '/relative/useNavigate/relative-useNavigate-b': typeof RelativeUseNavigateRelativeUseNavigateBRoute '/transition/count/create-resource': typeof TransitionCountCreateResourceRoute '/transition/typing/create-resource': typeof TransitionTypingCreateResourceRoute - '/대한민국/wildcard/$': typeof Char45824Char54620Char48124Char44397WildcardSplatRoute - '/대한민국/🚀/$id': typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRoute + '/대한민국/wildcard/$': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRoute + '/대한민국/🚀/$id': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRoute '/params-ps/named/': typeof ParamsPsNamedIndexRoute '/params-ps/wildcard/': typeof ParamsPsWildcardIndexRoute '/redirect/$target/': typeof RedirectTargetIndexRoute @@ -788,7 +796,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/non-nested': typeof NonNestedRouteRouteWithChildren - '/대한민국': typeof Char45824Char54620Char48124Char44397RouteRouteWithChildren + '/대한민국': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteWithChildren '/anchor': typeof AnchorRoute '/component-types-test': typeof ComponentTypesTestRoute '/editing-a': typeof EditingARoute @@ -843,8 +851,8 @@ export interface FileRoutesByTo { '/relative/useNavigate/relative-useNavigate-b': typeof RelativeUseNavigateRelativeUseNavigateBRoute '/transition/count/create-resource': typeof TransitionCountCreateResourceRoute '/transition/typing/create-resource': typeof TransitionTypingCreateResourceRoute - '/대한민국/wildcard/$': typeof Char45824Char54620Char48124Char44397WildcardSplatRoute - '/대한민국/🚀/$id': typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRoute + '/대한민국/wildcard/$': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRoute + '/대한민국/🚀/$id': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRoute '/params-ps/named': typeof ParamsPsNamedIndexRoute '/params-ps/wildcard': typeof ParamsPsWildcardIndexRoute '/redirect/$target': typeof RedirectTargetIndexRoute @@ -884,7 +892,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/non-nested': typeof NonNestedRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren - '/대한민국': typeof Char45824Char54620Char48124Char44397RouteRouteWithChildren + '/대한민국': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteWithChildren '/_layout': typeof LayoutRouteWithChildren '/anchor': typeof AnchorRoute '/component-types-test': typeof ComponentTypesTestRoute @@ -949,8 +957,8 @@ export interface FileRoutesById { '/relative/useNavigate/relative-useNavigate-b': typeof RelativeUseNavigateRelativeUseNavigateBRoute '/transition/count/create-resource': typeof TransitionCountCreateResourceRoute '/transition/typing/create-resource': typeof TransitionTypingCreateResourceRoute - '/대한민국/wildcard/$': typeof Char45824Char54620Char48124Char44397WildcardSplatRoute - '/대한민국/🚀/$id': typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRoute + '/대한민국/wildcard/$': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRoute + '/대한민국/🚀/$id': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRoute '/params-ps/named/': typeof ParamsPsNamedIndexRoute '/params-ps/wildcard/': typeof ParamsPsWildcardIndexRoute '/redirect/$target/': typeof RedirectTargetIndexRoute @@ -993,7 +1001,7 @@ export interface FileRouteTypes { | '/' | '/non-nested' | '/search-params' - | '/대한민국' + | '/대한민국' | '/anchor' | '/component-types-test' | '/editing-a' @@ -1055,8 +1063,8 @@ export interface FileRouteTypes { | '/relative/useNavigate/relative-useNavigate-b' | '/transition/count/create-resource' | '/transition/typing/create-resource' - | '/대한민국/wildcard/$' - | '/대한민국/🚀/$id' + | '/대한민국/wildcard/$' + | '/대한민국/🚀/$id' | '/params-ps/named/' | '/params-ps/wildcard/' | '/redirect/$target/' @@ -1096,7 +1104,7 @@ export interface FileRouteTypes { to: | '/' | '/non-nested' - | '/대한민국' + | '/대한민국' | '/anchor' | '/component-types-test' | '/editing-a' @@ -1151,8 +1159,8 @@ export interface FileRouteTypes { | '/relative/useNavigate/relative-useNavigate-b' | '/transition/count/create-resource' | '/transition/typing/create-resource' - | '/대한민국/wildcard/$' - | '/대한민국/🚀/$id' + | '/대한민국/wildcard/$' + | '/대한민국/🚀/$id' | '/params-ps/named' | '/params-ps/wildcard' | '/redirect/$target' @@ -1191,7 +1199,7 @@ export interface FileRouteTypes { | '/' | '/non-nested' | '/search-params' - | '/대한민국' + | '/대한민국' | '/_layout' | '/anchor' | '/component-types-test' @@ -1256,8 +1264,8 @@ export interface FileRouteTypes { | '/relative/useNavigate/relative-useNavigate-b' | '/transition/count/create-resource' | '/transition/typing/create-resource' - | '/대한민국/wildcard/$' - | '/대한민국/🚀/$id' + | '/대한민국/wildcard/$' + | '/대한민국/🚀/$id' | '/params-ps/named/' | '/params-ps/wildcard/' | '/redirect/$target/' @@ -1299,7 +1307,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute NonNestedRouteRoute: typeof NonNestedRouteRouteWithChildren SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren - Char45824Char54620Char48124Char44397RouteRoute: typeof Char45824Char54620Char48124Char44397RouteRouteWithChildren + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteWithChildren LayoutRoute: typeof LayoutRouteWithChildren AnchorRoute: typeof AnchorRoute ComponentTypesTestRoute: typeof ComponentTypesTestRoute @@ -1413,11 +1421,11 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } - '/대한민국': { - id: '/대한민국' - path: '/대한민국' - fullPath: '/대한민국' - preLoaderRoute: typeof Char45824Char54620Char48124Char44397RouteRouteImport + '/대한민국': { + id: '/대한민국' + path: '/대한민국' + fullPath: '/대한민국' + preLoaderRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteImport parentRoute: typeof rootRouteImport } '/search-params': { @@ -1609,19 +1617,19 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof ParamsPsNamedIndexRouteImport parentRoute: typeof rootRouteImport } - '/대한민국/🚀/$id': { - id: '/대한민국/🚀/$id' + '/대한민국/🚀/$id': { + id: '/대한민국/🚀/$id' path: '/🚀/$id' - fullPath: '/대한민국/🚀/$id' - preLoaderRoute: typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRouteImport - parentRoute: typeof Char45824Char54620Char48124Char44397RouteRoute + fullPath: '/대한민국/🚀/$id' + preLoaderRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRouteImport + parentRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRoute } - '/대한민국/wildcard/$': { - id: '/대한민국/wildcard/$' + '/대한민국/wildcard/$': { + id: '/대한민국/wildcard/$' path: '/wildcard/$' - fullPath: '/대한민국/wildcard/$' - preLoaderRoute: typeof Char45824Char54620Char48124Char44397WildcardSplatRouteImport - parentRoute: typeof Char45824Char54620Char48124Char44397RouteRoute + fullPath: '/대한민국/wildcard/$' + preLoaderRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRouteImport + parentRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRoute } '/transition/typing/create-resource': { id: '/transition/typing/create-resource' @@ -2298,22 +2306,22 @@ const SearchParamsRouteRouteChildren: SearchParamsRouteRouteChildren = { const SearchParamsRouteRouteWithChildren = SearchParamsRouteRoute._addFileChildren(SearchParamsRouteRouteChildren) -interface Char45824Char54620Char48124Char44397RouteRouteChildren { - Char45824Char54620Char48124Char44397WildcardSplatRoute: typeof Char45824Char54620Char48124Char44397WildcardSplatRoute - Char45824Char54620Char48124Char44397Char55357Char56960IdRoute: typeof Char45824Char54620Char48124Char44397Char55357Char56960IdRoute +interface Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteChildren { + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRoute + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRoute } -const Char45824Char54620Char48124Char44397RouteRouteChildren: Char45824Char54620Char48124Char44397RouteRouteChildren = +const Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteChildren: Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteChildren = { - Char45824Char54620Char48124Char44397WildcardSplatRoute: - Char45824Char54620Char48124Char44397WildcardSplatRoute, - Char45824Char54620Char48124Char44397Char55357Char56960IdRoute: - Char45824Char54620Char48124Char44397Char55357Char56960IdRoute, + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRoute: + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520WildcardSplatRoute, + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRoute: + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520Char55357Char56960IdRoute, } -const Char45824Char54620Char48124Char44397RouteRouteWithChildren = - Char45824Char54620Char48124Char44397RouteRoute._addFileChildren( - Char45824Char54620Char48124Char44397RouteRouteChildren, +const Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteWithChildren = + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRoute._addFileChildren( + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteChildren, ) interface LayoutLayout2RouteChildren { @@ -2506,8 +2514,8 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, NonNestedRouteRoute: NonNestedRouteRouteWithChildren, SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren, - Char45824Char54620Char48124Char44397RouteRoute: - Char45824Char54620Char48124Char44397RouteRouteWithChildren, + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRoute: + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520RouteRouteWithChildren, LayoutRoute: LayoutRouteWithChildren, AnchorRoute: AnchorRoute, ComponentTypesTestRoute: ComponentTypesTestRoute, diff --git "a/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/route.tsx" "b/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/route.tsx" index e996d48c76..a6d52c3756 100644 --- "a/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/route.tsx" +++ "b/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/route.tsx" @@ -1,6 +1,6 @@ import { Link, Outlet, createFileRoute } from '@tanstack/solid-router' -export const Route = createFileRoute('/대한민국')({ +export const Route = createFileRoute('/대한민국')({ component: RouteComponent, }) diff --git "a/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/wildcard.$.tsx" "b/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/wildcard.$.tsx" index d849f5675c..ccf7a6948b 100644 --- "a/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/wildcard.$.tsx" +++ "b/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/wildcard.$.tsx" @@ -1,6 +1,6 @@ import { createFileRoute } from '@tanstack/solid-router' -export const Route = createFileRoute('/대한민국/wildcard/$')({ +export const Route = createFileRoute('/대한민국/wildcard/$')({ component: RouteComponent, }) diff --git "a/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/\360\237\232\200.$id.tsx" "b/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/\360\237\232\200.$id.tsx" index 93bb5e561c..a2e40c4214 100644 --- "a/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/\360\237\232\200.$id.tsx" +++ "b/e2e/solid-router/basic-file-based/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255/\360\237\232\200.$id.tsx" @@ -1,6 +1,6 @@ import { createFileRoute } from '@tanstack/solid-router' -export const Route = createFileRoute('/대한민국/🚀/$id')({ +export const Route = createFileRoute('/대한민국/🚀/$id')({ component: RouteComponent, }) diff --git a/e2e/solid-start/basic/src/routes/prerender-params.$slug.tsx b/e2e/solid-start/basic/src/routes/prerender-params.$slug.tsx index 1bd2d4060a..ab6c6202f4 100644 --- a/e2e/solid-start/basic/src/routes/prerender-params.$slug.tsx +++ b/e2e/solid-start/basic/src/routes/prerender-params.$slug.tsx @@ -10,40 +10,40 @@ export const Route = createFileRoute('/prerender-params/$slug')({ sitemap: { changefreq: 'weekly', }, - prerenderParams: () => [ - { + *prerenderParams() { + yield { params: { slug: 'hello-world' }, sitemap: { lastmod: '2026-05-05', priority: 0.8, }, - }, - { + } + yield { params: { slug: '대한민국' }, sitemap: { priority: 0.6, }, - }, - { + } + yield { params: { slug: 'reserved?hash#plus+' }, sitemap: { exclude: true, }, - }, - { + } + yield { params: { slug: 'with-query' }, search: { page: 2, tag: 'router start' }, sitemap: { priority: 0.4, }, - }, - { + } + yield { params: { slug: getServerOnlyPrerenderSlug() }, sitemap: { exclude: true, }, - }, - ], + } + }, component: RouteComponent, }) diff --git a/e2e/solid-start/basic/tests/prerendering.spec.ts b/e2e/solid-start/basic/tests/prerendering.spec.ts index c256c0d600..11d68f6094 100644 --- a/e2e/solid-start/basic/tests/prerendering.spec.ts +++ b/e2e/solid-start/basic/tests/prerendering.spec.ts @@ -74,7 +74,7 @@ test.describe('Prerender Static Path Discovery', () => { expect(html).toContain('under-layout') }) - test('should contain prerendered content from route prerenderParams', () => { + test('should contain prerendered content from yielded route prerenderParams', () => { const htmlPath = join(distDir, 'prerender-params/hello-world/index.html') expect(existsSync(htmlPath)).toBe(true) diff --git a/e2e/vue-router/basic-file-based-sfc/src/routeTree.gen.ts b/e2e/vue-router/basic-file-based-sfc/src/routeTree.gen.ts index fa00eb1ce0..edb81a7b5b 100644 --- a/e2e/vue-router/basic-file-based-sfc/src/routeTree.gen.ts +++ b/e2e/vue-router/basic-file-based-sfc/src/routeTree.gen.ts @@ -8,7 +8,7 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { lazyRouteComponent } from '@tanstack/vue-router' +import { createFileRoute, lazyRouteComponent } from '@tanstack/vue-router' import { Route as rootRouteImport } from './routes/__root' import { Route as Char45824Char54620Char48124Char44397RouteImport } from './routes/대한민국' @@ -32,17 +32,28 @@ import { Route as LayoutLayout2LayoutARouteImport } from './routes/_layout/_layo import { Route as groupSubfolderInsideRouteImport } from './routes/(group)/subfolder/inside' import { Route as groupLayoutInsidelayoutRouteImport } from './routes/(group)/_layout.insidelayout' +const Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRouteImport = + createFileRoute('/대한민국')() + +const Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRoute = + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRouteImport.update( + { + id: '/대한민국', + path: '/대한민국', + getParentRoute: () => rootRouteImport, + } as any, + ).update({ + component: lazyRouteComponent( + () => import('./routes/대한민국.component.vue'), + 'default', + ), + }) const Char45824Char54620Char48124Char44397Route = Char45824Char54620Char48124Char44397RouteImport.update({ id: '/대한민국', path: '/대한민국', getParentRoute: () => rootRouteImport, - } as any).update({ - component: lazyRouteComponent( - () => import('./routes/대한민국.component.vue'), - 'default', - ), - }) + } as any) const RemountDepsRoute = RemountDepsRouteImport.update({ id: '/remountDeps', path: '/remountDeps', @@ -255,6 +266,7 @@ export interface FileRoutesByFullPath { '/posts': typeof PostsRouteWithChildren '/remountDeps': typeof RemountDepsRoute '/대한민국': typeof Char45824Char54620Char48124Char44397Route + '/대한민국': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRoute '/onlyrouteinside': typeof anotherGroupOnlyrouteinsideRoute '/inside': typeof groupInsideRoute '/lazyinside': typeof groupLazyinsideRoute @@ -273,6 +285,7 @@ export interface FileRoutesByTo { '/notRemountDeps': typeof NotRemountDepsRoute '/remountDeps': typeof RemountDepsRoute '/대한민국': typeof Char45824Char54620Char48124Char44397Route + '/대한민국': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRoute '/onlyrouteinside': typeof anotherGroupOnlyrouteinsideRoute '/inside': typeof groupInsideRoute '/lazyinside': typeof groupLazyinsideRoute @@ -294,6 +307,7 @@ export interface FileRoutesById { '/posts': typeof PostsRouteWithChildren '/remountDeps': typeof RemountDepsRoute '/대한민국': typeof Char45824Char54620Char48124Char44397Route + '/대한민국': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRoute '/(another-group)/onlyrouteinside': typeof anotherGroupOnlyrouteinsideRoute '/(group)/_layout': typeof groupLayoutRouteWithChildren '/(group)/inside': typeof groupInsideRoute @@ -317,6 +331,7 @@ export interface FileRouteTypes { | '/posts' | '/remountDeps' | '/대한민국' + | '/대한민국' | '/onlyrouteinside' | '/inside' | '/lazyinside' @@ -335,6 +350,7 @@ export interface FileRouteTypes { | '/notRemountDeps' | '/remountDeps' | '/대한민국' + | '/대한민국' | '/onlyrouteinside' | '/inside' | '/lazyinside' @@ -355,6 +371,7 @@ export interface FileRouteTypes { | '/posts' | '/remountDeps' | '/대한민국' + | '/대한민국' | '/(another-group)/onlyrouteinside' | '/(group)/_layout' | '/(group)/inside' @@ -378,6 +395,7 @@ export interface RootRouteChildren { PostsRoute: typeof PostsRouteWithChildren RemountDepsRoute: typeof RemountDepsRoute Char45824Char54620Char48124Char44397Route: typeof Char45824Char54620Char48124Char44397Route + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRoute anotherGroupOnlyrouteinsideRoute: typeof anotherGroupOnlyrouteinsideRoute groupLayoutRoute: typeof groupLayoutRouteWithChildren groupInsideRoute: typeof groupInsideRoute @@ -388,6 +406,13 @@ export interface RootRouteChildren { declare module '@tanstack/vue-router' { interface FileRoutesByPath { + '/대한민국': { + id: '/대한민국' + path: '/대한민국' + fullPath: '/대한민국' + preLoaderRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRouteImport + parentRoute: typeof rootRouteImport + } '/대한민국': { id: '/대한민국' path: '/대한민국' @@ -590,6 +615,8 @@ const rootRouteChildren: RootRouteChildren = { RemountDepsRoute: RemountDepsRoute, Char45824Char54620Char48124Char44397Route: Char45824Char54620Char48124Char44397Route, + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRoute: + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRoute, anotherGroupOnlyrouteinsideRoute: anotherGroupOnlyrouteinsideRoute, groupLayoutRoute: groupLayoutRouteWithChildren, groupInsideRoute: groupInsideRoute, diff --git a/e2e/vue-start/basic/src/routes/prerender-params.$slug.tsx b/e2e/vue-start/basic/src/routes/prerender-params.$slug.tsx index 43f56e23a5..1b62a45a57 100644 --- a/e2e/vue-start/basic/src/routes/prerender-params.$slug.tsx +++ b/e2e/vue-start/basic/src/routes/prerender-params.$slug.tsx @@ -10,40 +10,40 @@ export const Route = createFileRoute('/prerender-params/$slug')({ sitemap: { changefreq: 'weekly', }, - prerenderParams: () => [ - { + *prerenderParams() { + yield { params: { slug: 'hello-world' }, sitemap: { lastmod: '2026-05-05', priority: 0.8, }, - }, - { + } + yield { params: { slug: '대한민국' }, sitemap: { priority: 0.6, }, - }, - { + } + yield { params: { slug: 'reserved?hash#plus+' }, sitemap: { exclude: true, }, - }, - { + } + yield { params: { slug: 'with-query' }, search: { page: 2, tag: 'router start' }, sitemap: { priority: 0.4, }, - }, - { + } + yield { params: { slug: getServerOnlyPrerenderSlug() }, sitemap: { exclude: true, }, - }, - ], + } + }, component: RouteComponent, }) diff --git a/e2e/vue-start/basic/tests/prerendering.spec.ts b/e2e/vue-start/basic/tests/prerendering.spec.ts index c256c0d600..11d68f6094 100644 --- a/e2e/vue-start/basic/tests/prerendering.spec.ts +++ b/e2e/vue-start/basic/tests/prerendering.spec.ts @@ -74,7 +74,7 @@ test.describe('Prerender Static Path Discovery', () => { expect(html).toContain('under-layout') }) - test('should contain prerendered content from route prerenderParams', () => { + test('should contain prerendered content from yielded route prerenderParams', () => { const htmlPath = join(distDir, 'prerender-params/hello-world/index.html') expect(existsSync(htmlPath)).toBe(true) diff --git a/examples/vue/basic-file-based-sfc/src/routeTree.gen.ts b/examples/vue/basic-file-based-sfc/src/routeTree.gen.ts index fa00eb1ce0..edb81a7b5b 100644 --- a/examples/vue/basic-file-based-sfc/src/routeTree.gen.ts +++ b/examples/vue/basic-file-based-sfc/src/routeTree.gen.ts @@ -8,7 +8,7 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { lazyRouteComponent } from '@tanstack/vue-router' +import { createFileRoute, lazyRouteComponent } from '@tanstack/vue-router' import { Route as rootRouteImport } from './routes/__root' import { Route as Char45824Char54620Char48124Char44397RouteImport } from './routes/대한민국' @@ -32,17 +32,28 @@ import { Route as LayoutLayout2LayoutARouteImport } from './routes/_layout/_layo import { Route as groupSubfolderInsideRouteImport } from './routes/(group)/subfolder/inside' import { Route as groupLayoutInsidelayoutRouteImport } from './routes/(group)/_layout.insidelayout' +const Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRouteImport = + createFileRoute('/대한민국')() + +const Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRoute = + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRouteImport.update( + { + id: '/대한민국', + path: '/대한민국', + getParentRoute: () => rootRouteImport, + } as any, + ).update({ + component: lazyRouteComponent( + () => import('./routes/대한민국.component.vue'), + 'default', + ), + }) const Char45824Char54620Char48124Char44397Route = Char45824Char54620Char48124Char44397RouteImport.update({ id: '/대한민국', path: '/대한민국', getParentRoute: () => rootRouteImport, - } as any).update({ - component: lazyRouteComponent( - () => import('./routes/대한민국.component.vue'), - 'default', - ), - }) + } as any) const RemountDepsRoute = RemountDepsRouteImport.update({ id: '/remountDeps', path: '/remountDeps', @@ -255,6 +266,7 @@ export interface FileRoutesByFullPath { '/posts': typeof PostsRouteWithChildren '/remountDeps': typeof RemountDepsRoute '/대한민국': typeof Char45824Char54620Char48124Char44397Route + '/대한민국': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRoute '/onlyrouteinside': typeof anotherGroupOnlyrouteinsideRoute '/inside': typeof groupInsideRoute '/lazyinside': typeof groupLazyinsideRoute @@ -273,6 +285,7 @@ export interface FileRoutesByTo { '/notRemountDeps': typeof NotRemountDepsRoute '/remountDeps': typeof RemountDepsRoute '/대한민국': typeof Char45824Char54620Char48124Char44397Route + '/대한민국': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRoute '/onlyrouteinside': typeof anotherGroupOnlyrouteinsideRoute '/inside': typeof groupInsideRoute '/lazyinside': typeof groupLazyinsideRoute @@ -294,6 +307,7 @@ export interface FileRoutesById { '/posts': typeof PostsRouteWithChildren '/remountDeps': typeof RemountDepsRoute '/대한민국': typeof Char45824Char54620Char48124Char44397Route + '/대한민국': typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRoute '/(another-group)/onlyrouteinside': typeof anotherGroupOnlyrouteinsideRoute '/(group)/_layout': typeof groupLayoutRouteWithChildren '/(group)/inside': typeof groupInsideRoute @@ -317,6 +331,7 @@ export interface FileRouteTypes { | '/posts' | '/remountDeps' | '/대한민국' + | '/대한민국' | '/onlyrouteinside' | '/inside' | '/lazyinside' @@ -335,6 +350,7 @@ export interface FileRouteTypes { | '/notRemountDeps' | '/remountDeps' | '/대한민국' + | '/대한민국' | '/onlyrouteinside' | '/inside' | '/lazyinside' @@ -355,6 +371,7 @@ export interface FileRouteTypes { | '/posts' | '/remountDeps' | '/대한민국' + | '/대한민국' | '/(another-group)/onlyrouteinside' | '/(group)/_layout' | '/(group)/inside' @@ -378,6 +395,7 @@ export interface RootRouteChildren { PostsRoute: typeof PostsRouteWithChildren RemountDepsRoute: typeof RemountDepsRoute Char45824Char54620Char48124Char44397Route: typeof Char45824Char54620Char48124Char44397Route + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRoute anotherGroupOnlyrouteinsideRoute: typeof anotherGroupOnlyrouteinsideRoute groupLayoutRoute: typeof groupLayoutRouteWithChildren groupInsideRoute: typeof groupInsideRoute @@ -388,6 +406,13 @@ export interface RootRouteChildren { declare module '@tanstack/vue-router' { interface FileRoutesByPath { + '/대한민국': { + id: '/대한민국' + path: '/대한민국' + fullPath: '/대한민국' + preLoaderRoute: typeof Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRouteImport + parentRoute: typeof rootRouteImport + } '/대한민국': { id: '/대한민국' path: '/대한민국' @@ -590,6 +615,8 @@ const rootRouteChildren: RootRouteChildren = { RemountDepsRoute: RemountDepsRoute, Char45824Char54620Char48124Char44397Route: Char45824Char54620Char48124Char44397Route, + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRoute: + Char4355Char4450Char4370Char4449Char4523Char4358Char4469Char4523Char4352Char4462Char4520ComponentRoute, anotherGroupOnlyrouteinsideRoute: anotherGroupOnlyrouteinsideRoute, groupLayoutRoute: groupLayoutRouteWithChildren, groupInsideRoute: groupInsideRoute, diff --git a/packages/router-plugin/src/core/code-splitter/compilers.ts b/packages/router-plugin/src/core/code-splitter/compilers.ts index d59b4fa9af..3d521d69d1 100644 --- a/packages/router-plugin/src/core/code-splitter/compilers.ts +++ b/packages/router-plugin/src/core/code-splitter/compilers.ts @@ -743,7 +743,7 @@ export function compileCodeSplitReferenceRoute( if (opts.deleteNodes && opts.deleteNodes.size > 0) { routeOptions.properties = routeOptions.properties.filter( (prop) => { - if (t.isObjectProperty(prop)) { + if (t.isObjectProperty(prop) || t.isObjectMethod(prop)) { const key = getObjectPropertyKeyName(prop) if (key && opts.deleteNodes!.has(key as any)) { modified = true diff --git a/packages/router-plugin/src/core/utils.ts b/packages/router-plugin/src/core/utils.ts index 267582d6ec..389353d384 100644 --- a/packages/router-plugin/src/core/utils.ts +++ b/packages/router-plugin/src/core/utils.ts @@ -17,7 +17,7 @@ export function normalizePath(path: string): string { } export function getObjectPropertyKeyName( - prop: t.ObjectProperty, + prop: t.ObjectProperty | t.ObjectMethod, ): string | undefined { if (prop.computed) { return undefined diff --git a/packages/router-plugin/tests/delete-nodes.test.ts b/packages/router-plugin/tests/delete-nodes.test.ts index 6bb1d3dc6d..19da0fc839 100644 --- a/packages/router-plugin/tests/delete-nodes.test.ts +++ b/packages/router-plugin/tests/delete-nodes.test.ts @@ -34,6 +34,87 @@ const testGroups: Array<{ ] describe('code-splitter delete nodes', () => { + it('should delete route options declared as object methods', () => { + const compileResult = compileCodeSplitReferenceRoute({ + code: ` +import { createFileRoute } from '@tanstack/react-router' +import crypto from 'node:crypto' + +export const Route = createFileRoute('/')({ + ssr() { + return crypto.randomInt(0, 2) === 0 + }, + component: () =>
hello world
, +}) +`, + filename: 'ssr-method.tsx', + id: 'ssr-method.tsx', + addHmr: false, + codeSplitGroupings: [], + deleteNodes: new Set(['ssr']), + targetFramework: 'react', + }) + + expect(compileResult?.code).not.toContain('ssr()') + expect(compileResult?.code).not.toContain('node:crypto') + }) + + it('should strip prerenderParams and its server-only imports', () => { + const compileResult = compileCodeSplitReferenceRoute({ + code: ` +import { createFileRoute } from '@tanstack/react-router' +import { readdir } from 'node:fs/promises' + +export const Route = createFileRoute('/posts/$slug')({ + prerenderParams: async () => { + const entries = await readdir('content/posts') + return entries.map((slug) => ({ params: { slug } })) + }, + component: () =>
post
, +}) +`, + filename: 'posts.$slug.tsx', + id: 'posts.$slug.tsx', + addHmr: false, + codeSplitGroupings: [], + deleteNodes: new Set(['prerenderParams', 'sitemap']), + targetFramework: 'react', + }) + + expect(compileResult?.code).not.toContain('prerenderParams') + expect(compileResult?.code).not.toContain('readdir') + expect(compileResult?.code).not.toContain('node:fs/promises') + expect(compileResult?.code).not.toContain('content/posts') + }) + + it('should strip sitemap and its server-only imports', () => { + const compileResult = compileCodeSplitReferenceRoute({ + code: ` +import { createFileRoute } from '@tanstack/react-router' +import { computePriority } from './sitemap-priority.server' + +export const Route = createFileRoute('/about')({ + sitemap: { + priority: computePriority(), + changefreq: 'weekly', + }, + component: () =>
about
, +}) +`, + filename: 'about.tsx', + id: 'about.tsx', + addHmr: false, + codeSplitGroupings: [], + deleteNodes: new Set(['prerenderParams', 'sitemap']), + targetFramework: 'react', + }) + + expect(compileResult?.code).not.toContain('sitemap') + expect(compileResult?.code).not.toContain('computePriority') + expect(compileResult?.code).not.toContain('sitemap-priority.server') + expect(compileResult?.code).not.toContain('weekly') + }) + describe.each(frameworks)('FRAMEWORK=%s', (framework) => { describe.each(testGroups)( 'SPLIT_GROUP=$name', diff --git a/packages/start-client-core/src/prerenderParams.ts b/packages/start-client-core/src/prerenderParams.ts index bbb888c256..1ed2cbf02f 100644 --- a/packages/start-client-core/src/prerenderParams.ts +++ b/packages/start-client-core/src/prerenderParams.ts @@ -29,7 +29,7 @@ declare module '@tanstack/router-core' { prerenderParams?: ( ctx: PrerenderParamsContext, ) => Awaitable< - ReadonlyArray< + PrerenderParamsResult< PrerenderParamsEntry< Expand>, Expand> @@ -46,6 +46,11 @@ export interface PrerenderParamsContext { signal: AbortSignal } +export type PrerenderParamsResult = + | ReadonlyArray + | Iterable + | AsyncIterable + type PrerenderParamsSearch = unknown extends TSearch ? { search?: Record } : {} extends TSearch 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 dbee7860d8..5c94722e51 100644 --- a/packages/start-client-core/src/tests/prerenderParams.test-d.ts +++ b/packages/start-client-core/src/tests/prerenderParams.test-d.ts @@ -225,3 +225,45 @@ test('prerenderParams infers and requires search params', () => { expectTypeOf(missingSearch).toEqualTypeOf() }) + +test('prerenderParams accepts sync and async generators', () => { + const syncGenerator = { + *prerenderParams(ctx) { + expectTypeOf(ctx.routePath).toEqualTypeOf<'/posts/$slug'>() + expectTypeOf(ctx.signal).toEqualTypeOf() + + yield { + params: { + slug: 'hello-world', + }, + } + }, + } satisfies FileBaseRouteOptions< + unknown, + AnyRoute, + string, + '/posts/$slug', + undefined, + { slug: string } + > + + const asyncGenerator = { + async *prerenderParams() { + yield { + params: { + slug: 'hello-world', + }, + } + }, + } satisfies FileBaseRouteOptions< + unknown, + AnyRoute, + string, + '/posts/$slug', + undefined, + { slug: string } + > + + expectTypeOf(syncGenerator).toEqualTypeOf() + expectTypeOf(asyncGenerator).toEqualTypeOf() +}) diff --git a/packages/start-plugin-core/package.json b/packages/start-plugin-core/package.json index 3621646b28..d62489dcd3 100644 --- a/packages/start-plugin-core/package.json +++ b/packages/start-plugin-core/package.json @@ -29,6 +29,7 @@ "clean:snapshots": "rimraf **/*snapshot* --glob", "test": "pnpm test:eslint && pnpm test:types && pnpm test:build && pnpm test:unit", "test:unit": "vitest", + "test:perf": "vitest bench", "test:eslint": "eslint ./src", "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"", "test:types:ts55": "node ../../node_modules/typescript55/lib/tsc.js", diff --git a/packages/start-plugin-core/src/build-sitemap.ts b/packages/start-plugin-core/src/build-sitemap.ts index 200aca496d..5e704bddbc 100644 --- a/packages/start-plugin-core/src/build-sitemap.ts +++ b/packages/start-plugin-core/src/build-sitemap.ts @@ -1,216 +1,125 @@ -import { writeFileSync } from 'node:fs' +import { createWriteStream } from 'node:fs' import path from 'node:path' -import { create } from 'xmlbuilder2' -import { createLogger } from './utils' -import type { TanStackStartOutputConfig } from './schema' -import type { XMLBuilder } from 'xmlbuilder2/lib/interfaces' +import { createCB } from 'xmlbuilder2' +import type { Page } from './schema' +import type { XMLBuilderCB } from 'xmlbuilder2/lib/interfaces' -export type SitemapUrl = { - loc: string - lastmod: string - priority?: number - changefreq?: - | 'always' - | 'hourly' - | 'daily' - | 'weekly' - | 'monthly' - | 'yearly' - | 'never' - alternateRefs?: Array<{ - href: string - hreflang?: string - }> - images?: Array<{ - loc: string - title?: string - caption?: string - }> - news?: { - publication: { - name: string - language: string - } - publicationDate: string | Date - title: string - } -} - -export type SitemapData = { - urls: Array +export interface SitemapWriter { + write: (page: Page) => void + close: () => Promise } -function buildSitemapJson( - pages: TanStackStartOutputConfig['pages'], - host: string, -): SitemapData { - const slash = checkSlash(host) - - const urls: Array = pages - .filter((page) => { - return page.sitemap?.exclude !== true - }) - .map((page) => ({ - loc: `${host}${slash}${page.path.replace(/^\/+/g, '')}`, - lastmod: page.sitemap?.lastmod +export function createSitemapWriter({ + host, + outputPath, + publicDir, +}: { + host: string + outputPath: string + publicDir: string +}): SitemapWriter { + const slash = host.endsWith('/') ? '' : '/' + const stream = createWriteStream(path.join(publicDir, outputPath)) + + let endResolve!: () => void + let endReject!: (err: Error) => void + const endPromise = new Promise((resolve, reject) => { + endResolve = resolve + endReject = reject + }) + + stream.on('error', endReject) + + const builder: XMLBuilderCB = createCB({ + prettyPrint: true, + data: (chunk: string) => { + stream.write(chunk) + }, + end: () => { + stream.end(() => endResolve()) + }, + error: (err: Error) => { + stream.destroy(err) + endReject(err) + }, + }) + + builder.dec({ version: '1.0', encoding: 'UTF-8' }) + builder.com('This file was automatically generated by TanStack Start.') + builder.ele('urlset', { + 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', + }) + + return { + write(page) { + if (page.sitemap?.exclude === true) return + + const today = new Date().toISOString().split('T')[0]! + const lastmod = page.sitemap?.lastmod ? new Date(page.sitemap.lastmod).toISOString().split('T')[0]! - : new Date().toISOString().split('T')[0]!, - priority: page.sitemap?.priority, - changefreq: page.sitemap?.changefreq, - alternateRefs: page.sitemap?.alternateRefs, - images: page.sitemap?.images, - news: page.sitemap?.news, - })) - - return { urls } -} + : today -function jsonToXml(sitemapData: SitemapData): string { - const sitemap = createXml('urlset') + builder.ele('url') + builder + .ele('loc') + .txt(`${host}${slash}${page.path.replace(/^\/+/g, '')}`) + .up() + builder.ele('lastmod').txt(lastmod).up() - for (const item of sitemapData.urls) { - const page = sitemap.ele('url') - page.ele('loc').txt(item.loc) - page.ele('lastmod').txt(item.lastmod) - - if (item.priority !== undefined) { - page.ele('priority').txt(item.priority.toString()) - } - if (item.changefreq) { - page.ele('changefreq').txt(item.changefreq) - } + if (page.sitemap?.priority !== undefined) { + builder.ele('priority').txt(page.sitemap.priority.toString()).up() + } + if (page.sitemap?.changefreq) { + builder.ele('changefreq').txt(page.sitemap.changefreq).up() + } - // Add alternate references - if (item.alternateRefs?.length) { - for (const ref of item.alternateRefs) { - const alternateRef = page.ele('xhtml:link') - alternateRef.att('rel', 'alternate') - alternateRef.att('href', ref.href) - if (ref.hreflang) { - alternateRef.att('hreflang', ref.hreflang) + if (page.sitemap?.alternateRefs?.length) { + for (const ref of page.sitemap.alternateRefs) { + builder.ele('xhtml:link').att('rel', 'alternate').att('href', ref.href) + if (ref.hreflang) { + builder.att('hreflang', ref.hreflang) + } + builder.up() } } - } - // Add images - if (item.images?.length) { - for (const image of item.images) { - const imageElement = page.ele('image:image') - imageElement.ele('image:loc').txt(image.loc) - if (image.title) { - imageElement.ele('image:title').txt(image.title) - } - if (image.caption) { - imageElement.ele('image:caption').txt(image.caption) + if (page.sitemap?.images?.length) { + for (const image of page.sitemap.images) { + builder.ele('image:image') + builder.ele('image:loc').txt(image.loc).up() + if (image.title) { + builder.ele('image:title').txt(image.title).up() + } + if (image.caption) { + builder.ele('image:caption').txt(image.caption).up() + } + builder.up() } } - } - - // Add news - if (item.news) { - const newsElement = page.ele('news:news') - const publication = newsElement.ele('news:publication') - publication.ele('news:name').txt(item.news.publication.name) - publication.ele('news:language').txt(item.news.publication.language) - newsElement - .ele('news:publication_date') - .txt(new Date(item.news.publicationDate).toISOString().split('T')[0]!) - newsElement.ele('news:title').txt(item.news.title) - } - } - - return sitemap.end({ prettyPrint: true }) -} - -export function buildSitemap({ - startConfig, - publicDir, -}: { - startConfig: TanStackStartOutputConfig - publicDir: string -}) { - const logger = createLogger('sitemap') - let sitemapOptions = startConfig.sitemap - - if (!sitemapOptions && startConfig.pages.length) { - sitemapOptions = { enabled: true, outputPath: 'sitemap.xml' } - } - - if (!sitemapOptions?.enabled) { - throw new Error('Sitemap is not enabled') - } - - const { host, outputPath } = sitemapOptions - - if (!host) { - if (!startConfig.sitemap) { - logger.info( - 'Hint: Pages found, but no sitemap host has been set. To enable sitemap generation, set the `sitemap.host` option.', - ) - return - } - throw new Error( - 'Sitemap host is not set and required to build the sitemap.', - ) - } - - if (!outputPath) { - throw new Error('Sitemap output path is not set') - } - - const { pages } = startConfig - - if (!pages.length) { - logger.info('No pages were found to build the sitemap. Skipping...') - return - } - - logger.info('Building Sitemap...') - - // Build the sitemap data - const sitemapData = buildSitemapJson(pages, host) - - // Generate output paths - const xmlOutputPath = path.join(publicDir, outputPath) - const pagesOutputPath = path.join(publicDir, 'pages.json') - - try { - // Write XML sitemap - logger.info(`Writing sitemap XML at ${xmlOutputPath}`) - writeFileSync(xmlOutputPath, jsonToXml(sitemapData)) + if (page.sitemap?.news) { + const news = page.sitemap.news + builder.ele('news:news') + builder.ele('news:publication') + builder.ele('news:name').txt(news.publication.name).up() + builder.ele('news:language').txt(news.publication.language).up() + builder.up() + builder + .ele('news:publication_date') + .txt(new Date(news.publicationDate).toISOString().split('T')[0]!) + .up() + builder.ele('news:title').txt(news.title).up() + builder.up() + } - // Write pages data for runtime use - logger.info(`Writing pages data at ${pagesOutputPath}`) - writeFileSync( - pagesOutputPath, - JSON.stringify( - { - pages, - host, - lastBuilt: new Date().toISOString(), - }, - null, - 2, - ), - ) - } catch (e) { - logger.error(`Unable to write sitemap files`, e) + builder.up() + }, + async close() { + builder.end() + await endPromise + }, } } - -function createXml(elementName: 'urlset' | 'sitemapindex'): XMLBuilder { - return create({ version: '1.0', encoding: 'UTF-8' }) - .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.`) -} - -function checkSlash(host: string): string { - const finalChar = host.slice(-1) - return finalChar === '/' ? '' : '/' -} diff --git a/packages/start-plugin-core/src/post-build.ts b/packages/start-plugin-core/src/post-build.ts index a9c6898b35..6d6102aa38 100644 --- a/packages/start-plugin-core/src/post-build.ts +++ b/packages/start-plugin-core/src/post-build.ts @@ -1,10 +1,14 @@ import { HEADERS } from '@tanstack/start-server-core' -import { buildSitemap } from './build-sitemap' +import { createSitemapWriter } from './build-sitemap' import type { TanStackStartOutputConfig } from './schema' +import type { PrerenderPageSink } from './prerender' export interface StartPostBuildAdapter { getClientOutputDirectory: () => string - prerender: (startConfig: TanStackStartOutputConfig) => Promise + prerender: ( + startConfig: TanStackStartOutputConfig, + options?: { pageSink?: PrerenderPageSink }, + ) => Promise } export async function postBuild({ @@ -24,7 +28,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,14 +66,36 @@ export async function postBuild({ }) } - if (startConfig.prerender?.enabled) { - await adapter.prerender(startConfig) + const sitemap = startConfig.sitemap + if (sitemap?.enabled && !sitemap.host) { + throw new Error( + 'Sitemap host is not set and required to build the sitemap.', + ) + } + if (sitemap?.enabled && !sitemap.outputPath) { + throw new Error('Sitemap output path is not set') } - if (startConfig.sitemap?.enabled) { - buildSitemap({ - startConfig, - publicDir: adapter.getClientOutputDirectory(), - }) + const sitemapWriter = + sitemap?.enabled && sitemap.host && sitemap.outputPath + ? createSitemapWriter({ + host: sitemap.host, + outputPath: sitemap.outputPath, + publicDir: adapter.getClientOutputDirectory(), + }) + : null + + try { + if (startConfig.prerender.enabled) { + await adapter.prerender(startConfig, { + pageSink: sitemapWriter ? (page) => sitemapWriter.write(page) : undefined, + }) + } else if (sitemapWriter) { + for (const page of startConfig.pages) { + sitemapWriter.write(page) + } + } + } finally { + await sitemapWriter?.close() } } diff --git a/packages/start-plugin-core/src/prerender-params-runner.ts b/packages/start-plugin-core/src/prerender-params-runner.ts index c8ac212eb4..9df0e7b42f 100644 --- a/packages/start-plugin-core/src/prerender-params-runner.ts +++ b/packages/start-plugin-core/src/prerender-params-runner.ts @@ -5,10 +5,6 @@ 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 { @@ -24,10 +20,11 @@ interface PrerenderParamsEntry { export interface RunPrerenderParamsOptions { routeTree: AnyRoute | undefined - pages: Array + pages: Iterable logger: PrerenderParamsLogger filter?: (page: Page) => unknown prerenderParamsTimeout?: number + onPage: (page: Page) => void | Promise } export async function runPrerenderParams({ @@ -36,25 +33,46 @@ export async function runPrerenderParams({ logger, filter, prerenderParamsTimeout, -}: RunPrerenderParamsOptions): Promise> { + onPage, +}: RunPrerenderParamsOptions): Promise { const { routeOptions, dynamicRoutes, sitemapRoutes } = collectPrerenderRouteOptions(routeTree) - const pagesByPath = new Map(pages.map((page) => [page.path, page])) + + // Explicit pages may receive route-level sitemap merges and gap-fills from + // colliding generated entries. They are emitted after the dynamic-route pass + // so any patches are applied first. + const explicitByPath = new Map() + for (const page of pages) { + explicitByPath.set(page.path, page) + } for (const route of sitemapRoutes) { const options = routeOptions.get(route.routePath) if (!options?.sitemap) continue + if (isDynamicPath(route.path)) continue - const page = pagesByPath.get(route.path) - if (!page || isDynamicPath(route.path)) continue + const page = explicitByPath.get(route.path) + if (!page) continue - pagesByPath.set(route.path, merge(page, { sitemap: options.sitemap })) + explicitByPath.set(route.path, merge(page, { sitemap: options.sitemap })) } + const seen = new Set(explicitByPath.keys()) const controller = new AbortController() - const cleanupProcessAbort = attachProcessAbortHandlers(controller) + const abort = () => controller.abort() + process.once('SIGINT', abort) + process.once('SIGTERM', abort) try { + if ( + prerenderParamsTimeout !== undefined && + (!Number.isFinite(prerenderParamsTimeout) || prerenderParamsTimeout < 0) + ) { + throw new Error( + 'prerenderParamsTimeout must be a non-negative finite number', + ) + } + for (const route of dynamicRoutes) { const options = routeOptions.get(route.routePath) if (!options?.prerenderParams) continue @@ -66,157 +84,147 @@ export async function runPrerenderParams({ continue } - const cleanupTimeout = startPrerenderParamsTimeout( - controller, - prerenderParamsTimeout, - route.routePath, - ) + const timeoutId = + prerenderParamsTimeout === undefined + ? undefined + : setTimeout(() => { + controller.abort( + new Error( + `prerenderParams for route ${route.routePath} timed out`, + ), + ) + }, prerenderParamsTimeout) + + try { + throwIfAborted(controller.signal) + + const entries = await new Promise((resolve, reject) => { + const onAbort = () => + reject( + controller.signal.reason ?? new Error('prerenderParams aborted'), + ) + controller.signal.addEventListener('abort', onAbort, { once: true }) + + Promise.resolve() + .then(() => { + throwIfAborted(controller.signal) + return options.prerenderParams!({ + routePath: route.routePath, + signal: controller.signal, + }) + }) + .then(resolve, reject) + .finally(() => { + controller.signal.removeEventListener('abort', onAbort) + }) + }) + + if (!entries || typeof entries !== 'object') { + throw new Error( + `prerenderParams for route ${route.routePath} must return an array or iterable`, + ) + } - const entries = await runWithAbortSignal( - () => - options.prerenderParams!({ - routePath: route.routePath, - signal: controller.signal, - }), - controller.signal, - ).finally(cleanupTimeout) - - if (!Array.isArray(entries)) { - throw new Error( - `prerenderParams for route ${route.routePath} must return an array`, - ) - } + const asyncIter = (entries as AsyncIterable)[ + Symbol.asyncIterator + ] + const syncIter = (entries as Iterable)[Symbol.iterator] - for (const entry of entries) { - const page = createPageFromParams(route, options, entry) + if (typeof asyncIter !== 'function' && typeof syncIter !== 'function') { + throw new Error( + `prerenderParams for route ${route.routePath} must return an array or iterable`, + ) + } - if (filter && !filter(page)) { - continue + const visit = async (entry: unknown) => { + throwIfAborted(controller.signal) + + if ( + !entry || + typeof entry !== 'object' || + !('params' in entry) || + !entry.params || + typeof entry.params !== 'object' + ) { + throw new Error( + `prerenderParams entry for route ${route.routePath} must include params`, + ) + } + + const { params, search, sitemap, prerender } = + entry as PrerenderParamsEntry + + const { interpolatedPath, isMissingParams, usedParams } = + interpolatePath({ path: route.path, params }) + + if ( + isMissingParams || + Object.entries(usedParams).some( + ([key, value]) => key !== '*' && value == null, + ) + ) { + throw new Error( + `Missing prerenderParams values for route ${route.routePath}`, + ) + } + + const page: Page = { + path: + interpolatedPath + (search ? defaultStringifySearch(search) : ''), + sitemap: mergeOptions(options.sitemap, sitemap), + prerender: mergeOptions(options.prerender, prerender), + } + + if (filter && !filter(page)) return + + const explicit = explicitByPath.get(page.path) + if (explicit) { + explicitByPath.set(page.path, merge(page, explicit)) + return + } + + if (seen.has(page.path)) return + + seen.add(page.path) + await onPage(page) } - 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) + if (typeof asyncIter === 'function') { + for await (const entry of entries as AsyncIterable) { + await visit(entry) + } + } else { + for (const entry of entries as Iterable) { + await visit(entry) + } + } + + throwIfAborted(controller.signal) + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId) } } } finally { - cleanupProcessAbort() - } - - return Array.from(pagesByPath.values()) -} - -function attachProcessAbortHandlers(controller: AbortController) { - const abort = () => controller.abort() - - process.once('SIGINT', abort) - process.once('SIGTERM', abort) - - return () => { process.off('SIGINT', abort) process.off('SIGTERM', abort) } -} - -function startPrerenderParamsTimeout( - controller: AbortController, - timeout: number | undefined, - routePath: string, -) { - if (timeout === undefined) { - return () => {} - } - if (!Number.isFinite(timeout) || timeout < 0) { - throw new Error( - 'prerenderParamsTimeout must be a non-negative finite number', - ) + for (const page of explicitByPath.values()) { + await onPage(page) } - - const timeoutId = setTimeout(() => { - controller.abort( - new Error(`prerenderParams for route ${routePath} timed out`), - ) - }, timeout) - - return () => clearTimeout(timeoutId) -} - -async function runWithAbortSignal( - 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(() => { - if (signal.aborted) { - throw signal.reason ?? new Error('prerenderParams aborted') - } - - return callback() - }) - .then(resolve, reject) - .finally(() => { - signal.removeEventListener('abort', abort) - }) - }) } -function createPageFromParams( - route: PrerenderRouteMetadata, - options: PrerenderRouteOptions, - 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, +export async function collectPrerenderParams( + opts: Omit, +): Promise> { + const out: Array = [] + await runPrerenderParams({ + ...opts, + onPage: (page) => { + out.push(page) + }, }) - - if ( - isMissingParams || - Object.entries(usedParams).some( - ([key, value]) => key !== '*' && value == null, - ) - ) { - throw new Error( - `Missing prerenderParams values for route ${route.routePath}`, - ) - } - - return { - path: interpolatedPath + stringifySearch(entry.search), - sitemap: mergeOptions(options.sitemap, entry.sitemap), - prerender: mergeOptions(options.prerender, entry.prerender), - } -} - -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' - ) + return out } function merge(base: Page, override: Partial): Page { @@ -240,3 +248,9 @@ function mergeOptions( function isDynamicPath(path: string) { return path.includes('$') } + +function throwIfAborted(signal: AbortSignal) { + if (signal.aborted) { + throw signal.reason ?? new Error('prerenderParams aborted') + } +} diff --git a/packages/start-plugin-core/src/prerender-route-options.ts b/packages/start-plugin-core/src/prerender-route-options.ts index 456a11fdcc..429a7311f9 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, + PrerenderParamsResult, RoutePrerenderOptions, RouteSitemapOptions, } from '@tanstack/start-client-core' @@ -15,8 +16,10 @@ export interface PrerenderRouteOptions { routePath: string signal: AbortSignal }) => - | ReadonlyArray>> - | Promise>>> + | PrerenderParamsResult>> + | Promise< + PrerenderParamsResult>> + > prerender?: RoutePrerenderOptions sitemap?: RouteSitemapOptions } diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts index a73648b369..80911ec89f 100644 --- a/packages/start-plugin-core/src/prerender.ts +++ b/packages/start-plugin-core/src/prerender.ts @@ -15,124 +15,118 @@ export interface PrerenderHandler { close?: () => Promise } +export type PrerenderPageSink = (page: Page) => void | Promise + export async function prerender({ startConfig, handler, + pageSink, }: { startConfig: TanStackStartOutputConfig handler: PrerenderHandler + pageSink?: PrerenderPageSink }) { const logger = createLogger('prerender') logger.info('Prerendering pages...') try { - if (startConfig.prerender?.enabled) { - let pages = startConfig.pages.length ? startConfig.pages : [{ path: '/' }] + if (!startConfig.prerender?.enabled) { + return + } - if (startConfig.prerender.autoStaticPathsDiscovery ?? true) { - const pagesMap = new Map(pages.map((item) => [item.path, item])) - const discoveredPages = globalThis.TSS_PRERENDABLE_PATHS || [] + let initialPages = startConfig.pages.length + ? startConfig.pages + : [{ path: '/' }] - for (const page of discoveredPages) { - if (!pagesMap.has(page.path)) { - pagesMap.set(page.path, page) - } - } + if (startConfig.prerender.autoStaticPathsDiscovery ?? true) { + const pagesMap = new Map(initialPages.map((item) => [item.path, item])) + const discoveredPages = globalThis.TSS_PRERENDABLE_PATHS || [] - pages = Array.from(pagesMap.values()) - } - - if (!startConfig.spa?.enabled) { - if (!globalThis.TSS_PRERENDER_ROUTE_TREE) { - throw new Error('Prerender route options were not loaded') + for (const page of discoveredPages) { + if (!pagesMap.has(page.path)) { + pagesMap.set(page.path, page) } - - 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 - } - - const pages = await prerenderPages({ - outputDir: handler.getClientOutputDirectory(), - }) - - logger.info(`Prerendered ${pages.length} pages:`) - pages.forEach((page) => { - logger.info(`- ${page}`) - }) - } catch (error) { - logger.error(error) - throw error - } finally { - delete globalThis.TSS_PRERENDER_ROUTE_TREE - await handler.close?.() - } - - function extractLinks(html: string): Array { - const linkRegex = /]+href=["']([^"']+)["'][^>]*>/g - const links: Array = [] - let match: RegExpExecArray | null - - while ((match = linkRegex.exec(html)) !== null) { - const href = match[1] - if (href && (href.startsWith('/') || href.startsWith('./'))) { - links.push(href) - } + initialPages = Array.from(pagesMap.values()) } - return links - } - - async function prerenderPages({ outputDir }: { outputDir: string }) { + const outputDir = handler.getClientOutputDirectory() + const concurrency = + startConfig.prerender.concurrency ?? os.cpus().length + const maxPending = Math.max(concurrency * 4, concurrency + 1) + // +1 reserves a slot for the streaming sentinel below. + const queue = new Queue({ concurrency: concurrency + 1 }) const seen = new Set() const prerendered = new Set() const retriesByPath = new Map() - const concurrency = startConfig.prerender?.concurrency ?? os.cpus().length - logger.info(`Concurrency: ${concurrency}`) - const queue = new Queue({ concurrency }) const routerBasePath = joinURL('/', startConfig.router.basepath ?? '') const routerBaseUrl = new URL(routerBasePath, 'http://localhost') + const filter = startConfig.prerender.filter + + logger.info(`Concurrency: ${concurrency}`) + + let streamingResolve!: () => void + const streamingDone = new Promise((resolve) => { + streamingResolve = resolve + }) + queue.add(() => streamingDone) + const queueComplete = queue.start() + + const seedPage = async (page: Page) => { + const normalized = validateAndNormalizePrerenderPage(page, routerBaseUrl) + addCrawlPageTask(normalized) + + if (queue.getPending().length < maxPending) return + await new Promise((resolve) => { + const off = queue.onSettled(() => { + if (queue.getPending().length < maxPending) { + off() + resolve() + } + }) + }) + } - startConfig.pages = validateAndNormalizePrerenderPages( - startConfig.pages, - routerBaseUrl, - ) + if (!startConfig.spa?.enabled) { + if (!globalThis.TSS_PRERENDER_ROUTE_TREE) { + throw new Error('Prerender route options were not loaded') + } - startConfig.pages.forEach((page) => addCrawlPageTask(page)) + const routeTree = await globalThis.TSS_PRERENDER_ROUTE_TREE() - if (queue.isSettled()) { - logger.info('No pages matched prerender filter; skipping.') - return Array.from(prerendered) + await runPrerenderParams({ + routeTree, + pages: initialPages, + logger, + filter, + prerenderParamsTimeout: + startConfig.prerender.prerenderParamsTimeout, + onPage: seedPage, + }) + } else { + for (const page of initialPages) { + await seedPage(page) + } } - await queue.start() + streamingResolve() - return Array.from(prerendered) + await queueComplete + + logger.info(`Prerendered ${prerendered.size} pages:`) + for (const pagePath of prerendered) { + logger.info(`- ${pagePath}`) + } function addCrawlPageTask(page: Page) { if (seen.has(page.path)) return - seen.add(page.path) - if (page.fromCrawl) { - startConfig.pages.push(page) - } - - if (!(page.prerender?.enabled ?? true)) return + if (filter && !filter(page)) return - if ( - startConfig.prerender?.filter && - !startConfig.prerender.filter(page) - ) { + if (!(page.prerender?.enabled ?? true)) { + if (pageSink) pageSink(page) return } @@ -215,15 +209,25 @@ export async function prerender({ Object.assign(page, newPage) } + if (pageSink) await pageSink(page) + if (prerenderOptions.crawlLinks ?? true) { - const links = extractLinks(html) - for (const link of links) { - addCrawlPageTask({ path: link, fromCrawl: true }) + const linkRegex = /]+href=["']([^"']+)["'][^>]*>/g + let match: RegExpExecArray | null + while ((match = linkRegex.exec(html)) !== null) { + const href = match[1] + if (href && (href.startsWith('/') || href.startsWith('./'))) { + addCrawlPageTask({ path: href, fromCrawl: true }) + } } } } catch (error) { if (retries < (prerenderOptions.retryCount ?? 0)) { - const retryDelay = normalizeRetryDelay(prerenderOptions.retryDelay) + const rawDelay = Number(prerenderOptions.retryDelay) + const retryDelay = + !Number.isFinite(rawDelay) || rawDelay < 0 + ? DEFAULT_RETRY_DELAY + : Math.trunc(rawDelay) logger.warn( `Encountered error, retrying: ${page.path} in ${retryDelay}ms`, ) @@ -236,37 +240,36 @@ export async function prerender({ } }) } - } - - function normalizeRetryDelay(value: number | undefined): number { - const retryDelay = Number(value) - - if (!Number.isFinite(retryDelay) || retryDelay < 0) { - return DEFAULT_RETRY_DELAY - } - - return Math.trunc(retryDelay) - } - - async function requestWithRedirects( - path: string, - options?: RequestInit, - maxRedirects: number = 5, - ): Promise { - const response = await handler.request(path, options) - if (isRedirectResponse(response) && maxRedirects > 0) { - const location = response.headers.get('location')! + async function requestWithRedirects( + pagePath: string, + options?: RequestInit, + maxRedirects: number = 5, + ): Promise { + const response = await handler.request(pagePath, options) + + if (isRedirectResponse(response) && maxRedirects > 0) { + const location = response.headers.get('location')! + + if ( + location.startsWith('http://localhost') || + location.startsWith('/') + ) { + const nextPath = location.replace('http://localhost', '') + return requestWithRedirects(nextPath, options, maxRedirects - 1) + } - if (location.startsWith('http://localhost') || location.startsWith('/')) { - const nextPath = location.replace('http://localhost', '') - return requestWithRedirects(nextPath, options, maxRedirects - 1) + logger.warn(`Skipping redirect to external location: ${location}`) } - logger.warn(`Skipping redirect to external location: ${location}`) + return response } - - return response + } catch (error) { + logger.error(error) + throw error + } finally { + delete globalThis.TSS_PRERENDER_ROUTE_TREE + await handler.close?.() } } @@ -274,29 +277,27 @@ function isRedirectResponse(res: Response) { return res.status >= 300 && res.status < 400 && res.headers.get('location') } -export function validateAndNormalizePrerenderPages( - pages: Array, +export function validateAndNormalizePrerenderPage( + page: Page, routerBaseUrl: URL, -): Array { - return pages.map((page) => { - let url: URL - try { - url = new URL(page.path, routerBaseUrl) - } catch (err) { - throw new Error(`prerender page path must be relative: ${page.path}`, { - cause: err, - }) - } +): Page { + let url: URL + try { + url = new URL(page.path, routerBaseUrl) + } catch (err) { + throw new Error(`prerender page path must be relative: ${page.path}`, { + cause: err, + }) + } - if (url.origin !== 'http://localhost') { - throw new Error(`prerender page path must be relative: ${page.path}`) - } + if (url.origin !== 'http://localhost') { + throw new Error(`prerender page path must be relative: ${page.path}`) + } - const decodedPathname = decodeURI(url.pathname) + const decodedPathname = decodeURI(url.pathname) - return { - ...page, - path: decodedPathname + url.search + url.hash, - } - }) + return { + ...page, + path: decodedPathname + url.search + url.hash, + } } diff --git a/packages/start-plugin-core/src/rsbuild/post-build.ts b/packages/start-plugin-core/src/rsbuild/post-build.ts index 6316e75a50..4fcba9eb50 100644 --- a/packages/start-plugin-core/src/rsbuild/post-build.ts +++ b/packages/start-plugin-core/src/rsbuild/post-build.ts @@ -28,7 +28,7 @@ export async function postBuildWithRsbuild({ getClientOutputDirectory() { return clientOutputDirectory }, - async prerender(startConfig) { + async prerender(startConfig, options) { const handler = await createRsbuildPrerenderHandler({ clientOutputDirectory, serverOutputDirectory, @@ -39,6 +39,7 @@ export async function postBuildWithRsbuild({ return prerender({ startConfig, handler, + pageSink: options?.pageSink, }) }, }, diff --git a/packages/start-plugin-core/src/vite/post-server-build.ts b/packages/start-plugin-core/src/vite/post-server-build.ts index 2e74b7635d..ead1a0dcbd 100644 --- a/packages/start-plugin-core/src/vite/post-server-build.ts +++ b/packages/start-plugin-core/src/vite/post-server-build.ts @@ -17,10 +17,11 @@ export async function postServerBuild({ getClientOutputDirectory() { return getClientOutputDirectory(builder.config) }, - prerender(startConfig) { + prerender(startConfig, options) { return prerenderWithVite({ startConfig, builder, + pageSink: options?.pageSink, }) }, }, diff --git a/packages/start-plugin-core/src/vite/prerender.ts b/packages/start-plugin-core/src/vite/prerender.ts index f039057e12..9a28603649 100644 --- a/packages/start-plugin-core/src/vite/prerender.ts +++ b/packages/start-plugin-core/src/vite/prerender.ts @@ -11,16 +11,18 @@ import { import { getBundlerOptions } from '../utils' import { getServerOutputDirectory } from './output-directory' import type { TanStackStartOutputConfig } from '../schema' -import type { PrerenderHandler } from '../prerender' +import type { PrerenderHandler, PrerenderPageSink } from '../prerender' import type { Dirent } from 'node:fs' import type { PreviewServer, ResolvedConfig, ViteBuilder } from 'vite' export async function prerenderWithVite({ startConfig, builder, + pageSink, }: { startConfig: TanStackStartOutputConfig builder: ViteBuilder + pageSink?: PrerenderPageSink }) { const serverEnv = builder.environments[VITE_ENVIRONMENT_NAMES.server] @@ -87,6 +89,7 @@ export async function prerenderWithVite({ return prerender({ startConfig, handler, + pageSink, }) } diff --git a/packages/start-plugin-core/tests/build-sitemap.test.ts b/packages/start-plugin-core/tests/build-sitemap.test.ts index afc105ac70..5feb1abeb9 100644 --- a/packages/start-plugin-core/tests/build-sitemap.test.ts +++ b/packages/start-plugin-core/tests/build-sitemap.test.ts @@ -1,8 +1,9 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +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' +import { createSitemapWriter } from '../src/build-sitemap' +import type { Page } from '../src/schema' const tempDirs: Array = [] @@ -12,71 +13,69 @@ afterEach(() => { } }) -describe('buildSitemap', () => { - it('includes generated page search params unless excluded', () => { - const publicDir = mkdtempSync(join(tmpdir(), 'tanstack-start-sitemap-')) - tempDirs.push(publicDir) +async function writeSitemap(opts: { + host: string + outputPath?: string + pages: Array +}) { + const publicDir = mkdtempSync(join(tmpdir(), 'tanstack-start-sitemap-')) + tempDirs.push(publicDir) + + const outputPath = opts.outputPath ?? 'sitemap.xml' + const writer = createSitemapWriter({ + host: opts.host, + outputPath, + publicDir, + }) - buildSitemap({ - publicDir, - startConfig: { - sitemap: { - enabled: true, - host: 'https://example.com', - outputPath: 'sitemap.xml', + for (const page of opts.pages) { + writer.write(page) + } + await writer.close() + + return readFileSync(join(publicDir, outputPath), 'utf-8') +} + +describe('createSitemapWriter', () => { + it('includes generated page search params unless excluded', async () => { + const sitemap = await writeSitemap({ + host: 'https://example.com', + pages: [ + { path: '/products/router?page=2&tag=start' }, + { + path: '/products/draft?preview=true', + sitemap: { exclude: true }, }, - 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', - }, - ], - }, + it('preserves sitemap metadata for query URLs without duplicating host slashes', async () => { + const sitemap = await writeSitemap({ + host: 'https://example.com/', + 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', ) @@ -86,109 +85,51 @@ describe('buildSitemap', () => { 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('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, + it('preserves a deployment base path from the sitemap host', async () => { + const sitemap = await writeSitemap({ + host: 'https://example.com/docs/', + pages: [{ path: '/guide/start' }], }) - 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('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: { + it('writes advanced sitemap metadata', async () => { + const sitemap = await writeSitemap({ + host: 'https://example.com', + pages: [ + { + path: '/news/router-launch', 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) - - 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', + 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"', ) 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 b6479b611e..290e439743 100644 --- a/packages/start-plugin-core/tests/post-server-build.test.ts +++ b/packages/start-plugin-core/tests/post-server-build.test.ts @@ -7,10 +7,36 @@ vi.mock('@tanstack/start-server-core', () => ({ })) vi.mock('../src/build-sitemap', () => ({ - buildSitemap: vi.fn(), + createSitemapWriter: vi.fn(() => ({ + write: () => {}, + close: async () => {}, + })), })) describe('postServerBuild', () => { + it('throws when sitemap is enabled without a host', async () => { + const prerender = vi.fn(async () => {}) + const { postBuild } = await import('../src/post-build') + + await expect( + postBuild({ + startConfig: { + sitemap: { enabled: true, outputPath: 'sitemap.xml' }, + pages: [{ path: '/guide/start' }], + router: { basepath: '' }, + serverFns: { base: '' }, + spa: { enabled: false }, + } as any, + adapter: { + getClientOutputDirectory: () => '/client', + prerender, + }, + }), + ).rejects.toThrow( + 'Sitemap host is not set and required to build the sitemap.', + ) + }) + 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') @@ -100,6 +126,7 @@ describe('postServerBuild', () => { enabled: true, }), }), + expect.anything(), ) }) @@ -139,6 +166,7 @@ describe('postServerBuild', () => { autoStaticPathsDiscovery: false, }), }), + expect.anything(), ) }) }) diff --git a/packages/start-plugin-core/tests/prerender-params-runner.bench.ts b/packages/start-plugin-core/tests/prerender-params-runner.bench.ts new file mode 100644 index 0000000000..9e7eed2335 --- /dev/null +++ b/packages/start-plugin-core/tests/prerender-params-runner.bench.ts @@ -0,0 +1,162 @@ +import { bench, describe } from 'vitest' +import { + collectPrerenderParams, + runPrerenderParams, +} from '../src/prerender-params-runner' + +const paramsCount = Number(process.env.TSS_PRERENDER_PARAMS_COUNT ?? 100_000) +const pageSize = 1_000 +const pageCount = Math.ceil(paramsCount / pageSize) +const logger = { + warn: () => {}, +} +const noopOnPage = () => {} + +function createRouteTree(prerenderParams: () => unknown) { + return { + options: {}, + children: [ + { + id: '/posts/$slug', + fullPath: '/posts/$slug', + options: { + id: '/posts/$slug', + prerenderParams, + }, + }, + ], + } as any +} + +function createArrayEntries() { + return Array.from({ length: paramsCount }, (_, i) => ({ + params: { slug: `post-${i}` }, + })) +} + +const prebuiltArrayEntries = createArrayEntries() + +function* syncGeneratorEntries() { + for (let i = 0; i < paramsCount; i++) { + yield { params: { slug: `post-${i}` } } + } +} + +async function* asyncGeneratorEntries() { + for (let i = 0; i < paramsCount; i++) { + yield { params: { slug: `post-${i}` } } + } +} + +async function fetchEntriesPage(page: number) { + await Promise.resolve() + const start = page * pageSize + const length = Math.min(pageSize, paramsCount - start) + + return Array.from({ length }, (_, i) => ({ + params: { slug: `post-${start + i}` }, + })) +} + +async function createPagedArrayEntries() { + const entries = [] + + for (let page = 0; page < pageCount; page++) { + entries.push(...(await fetchEntriesPage(page))) + } + + return entries +} + +async function* asyncGeneratorPagedEntries() { + for (let page = 0; page < pageCount; page++) { + yield* await fetchEntriesPage(page) + } +} + +function runStreaming(prerenderParams: () => unknown) { + return runPrerenderParams({ + routeTree: createRouteTree(prerenderParams), + pages: [], + logger, + onPage: noopOnPage, + }) +} + +function runCollect(prerenderParams: () => unknown) { + return collectPrerenderParams({ + routeTree: createRouteTree(prerenderParams), + pages: [], + logger, + }) +} + +describe(`prerenderParams streaming (${paramsCount} entries)`, () => { + bench( + 'prebuilt sync array', + () => runStreaming(() => prebuiltArrayEntries), + { warmupIterations: 1 }, + ) + + bench( + 'created sync array', + () => runStreaming(() => createArrayEntries()), + { warmupIterations: 1 }, + ) + + bench( + 'created async array', + () => runStreaming(async () => createArrayEntries()), + { warmupIterations: 1 }, + ) + + bench( + 'sync generator', + () => runStreaming(() => syncGeneratorEntries()), + { warmupIterations: 1 }, + ) + + bench( + 'async generator', + () => runStreaming(() => asyncGeneratorEntries()), + { warmupIterations: 1 }, + ) + + bench( + 'created async array from pages', + () => runStreaming(() => createPagedArrayEntries()), + { warmupIterations: 1 }, + ) + + bench( + 'async generator from pages', + () => runStreaming(() => asyncGeneratorPagedEntries()), + { warmupIterations: 1 }, + ) +}) + +describe(`prerenderParams collect (${paramsCount} entries)`, () => { + bench( + 'prebuilt sync array', + async () => { + await runCollect(() => prebuiltArrayEntries) + }, + { warmupIterations: 1 }, + ) + + bench( + 'sync generator', + async () => { + await runCollect(() => syncGeneratorEntries()) + }, + { warmupIterations: 1 }, + ) + + bench( + 'async generator', + async () => { + await runCollect(() => asyncGeneratorEntries()) + }, + { warmupIterations: 1 }, + ) +}) 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 2b0a4175ac..7782f08d78 100644 --- a/packages/start-plugin-core/tests/prerender-params-runner.test.ts +++ b/packages/start-plugin-core/tests/prerender-params-runner.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest' -import { runPrerenderParams } from '../src/prerender-params-runner' +import { + collectPrerenderParams, + runPrerenderParams, +} from '../src/prerender-params-runner' +import type { Page } from '../src/schema' const logger = { warn: vi.fn(), @@ -19,7 +23,7 @@ function createRouteTree(optionsById: Record) { } as any } -describe('runPrerenderParams', () => { +describe('collectPrerenderParams', () => { it('expands dynamic route params into pages', async () => { const routeTree = createRouteTree({ '/posts/$slug': { @@ -35,7 +39,7 @@ describe('runPrerenderParams', () => { }, }) - const pages = await runPrerenderParams({ + const pages = await collectPrerenderParams({ routeTree, pages: [], logger, @@ -50,6 +54,49 @@ describe('runPrerenderParams', () => { ]) }) + it('supports sync, async, and generator prerenderParams returns', async () => { + const routeTree = createRouteTree({ + '/sync/$slug': { + prerenderParams: () => [{ params: { slug: 'array' } }], + }, + '/async/$slug': { + prerenderParams: async () => [{ params: { slug: 'promise' } }], + }, + '/yield/$slug': { + *prerenderParams() { + yield { params: { slug: 'generator' } } + }, + }, + '/async-yield/$slug': { + async *prerenderParams() { + await Promise.resolve() + yield { params: { slug: 'async-generator' } } + }, + }, + '/promise-yield/$slug': { + async prerenderParams() { + return (function* () { + yield { params: { slug: 'promise-generator' } } + })() + }, + }, + }) + + const pages = await collectPrerenderParams({ + routeTree, + pages: [], + logger, + }) + + expect(pages.map((page) => page.path)).toEqual([ + '/sync/array', + '/async/promise', + '/yield/generator', + '/async-yield/async-generator', + '/promise-yield/promise-generator', + ]) + }) + it('supports multiple params, optional params, and splats', async () => { const routeTree = createRouteTree({ '/posts/$category/$slug': { @@ -71,7 +118,7 @@ describe('runPrerenderParams', () => { }, }) - const pages = await runPrerenderParams({ + const pages = await collectPrerenderParams({ routeTree, pages: [], logger, @@ -100,7 +147,7 @@ describe('runPrerenderParams', () => { }, }) - const pages = await runPrerenderParams({ + const pages = await collectPrerenderParams({ routeTree, pages: [], logger, @@ -126,7 +173,7 @@ describe('runPrerenderParams', () => { }, }) - const pages = await runPrerenderParams({ + const pages = await collectPrerenderParams({ routeTree, pages: [], logger, @@ -182,7 +229,7 @@ describe('runPrerenderParams', () => { }, }) - const pages = await runPrerenderParams({ + const pages = await collectPrerenderParams({ routeTree, pages: [], logger, @@ -242,7 +289,7 @@ describe('runPrerenderParams', () => { }, }) - const pages = await runPrerenderParams({ + const pages = await collectPrerenderParams({ routeTree, pages: [], logger, @@ -267,7 +314,7 @@ describe('runPrerenderParams', () => { }) await expect( - runPrerenderParams({ + collectPrerenderParams({ routeTree, pages: [], logger, @@ -296,7 +343,38 @@ describe('runPrerenderParams', () => { }, }) - const result = runPrerenderParams({ + const result = collectPrerenderParams({ + 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('passes timeout aborts to async generators through the signal', async () => { + vi.useFakeTimers() + try { + const routeTree = createRouteTree({ + '/products/$slug': { + async *prerenderParams({ signal }: any) { + yield { params: { slug: 'router' } } + await new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(signal.reason)) + }) + }, + }, + }) + + const result = collectPrerenderParams({ routeTree, pages: [], logger, @@ -313,6 +391,32 @@ describe('runPrerenderParams', () => { } }) + it('closes sync iterators when a yielded entry is invalid', async () => { + const close = vi.fn(() => ({ done: true, value: undefined })) + const iterator = { + next: vi.fn(() => ({ done: false, value: {} })), + return: close, + } + const routeTree = createRouteTree({ + '/products/$slug': { + prerenderParams: () => ({ + [Symbol.iterator]: () => iterator, + }), + }, + }) + + await expect( + collectPrerenderParams({ + routeTree, + pages: [], + logger, + }), + ).rejects.toThrow( + 'prerenderParams entry for route /products/$slug must include params', + ) + expect(close).toHaveBeenCalledTimes(1) + }) + it('aborts prerenderParams when the process is interrupted', async () => { const prerenderParams = vi.fn(({ signal }) => { return new Promise((_, reject) => { @@ -327,7 +431,7 @@ describe('runPrerenderParams', () => { }, }) - const result = runPrerenderParams({ + const result = collectPrerenderParams({ routeTree, pages: [], logger, @@ -345,7 +449,7 @@ describe('runPrerenderParams', () => { }, }) - const pages = await runPrerenderParams({ + const pages = await collectPrerenderParams({ routeTree, pages: [{ path: '/about', sitemap: { priority: 0.5 } }], logger, @@ -373,7 +477,7 @@ describe('runPrerenderParams', () => { }, }) - const pages = await runPrerenderParams({ + const pages = await collectPrerenderParams({ routeTree, pages: [ { @@ -394,7 +498,7 @@ describe('runPrerenderParams', () => { ]) }) - it('lets the first generated page take precedence over generated duplicates', async () => { + it('drops generated duplicates after the first emission', async () => { const routeTree = createRouteTree({ '/posts/$slug': { sitemap: { changefreq: 'daily' }, @@ -413,7 +517,7 @@ describe('runPrerenderParams', () => { }, }) - const pages = await runPrerenderParams({ + const pages = await collectPrerenderParams({ routeTree, pages: [], logger, @@ -425,7 +529,6 @@ describe('runPrerenderParams', () => { sitemap: { changefreq: 'daily', priority: 0.7, - lastmod: '2026-05-05', }, prerender: { retryCount: 1 }, }, @@ -442,7 +545,7 @@ describe('runPrerenderParams', () => { }, }) - const pages = await runPrerenderParams({ + const pages = await collectPrerenderParams({ routeTree, pages: [], logger, @@ -460,7 +563,7 @@ describe('runPrerenderParams', () => { }, }) - const pages = await runPrerenderParams({ + const pages = await collectPrerenderParams({ routeTree, pages: [], logger, @@ -480,7 +583,7 @@ describe('runPrerenderParams', () => { }) await expect( - runPrerenderParams({ + collectPrerenderParams({ routeTree, pages: [], logger, @@ -488,7 +591,7 @@ describe('runPrerenderParams', () => { ).rejects.toThrow('Missing prerenderParams values for route /posts/$slug') }) - it('throws when prerenderParams does not return an array', async () => { + it('throws when prerenderParams does not return an array or iterable', async () => { const routeTree = createRouteTree({ '/posts/$slug': { prerenderParams: () => undefined, @@ -496,13 +599,13 @@ describe('runPrerenderParams', () => { }) await expect( - runPrerenderParams({ + collectPrerenderParams({ routeTree, pages: [], logger, }), ).rejects.toThrow( - 'prerenderParams for route /posts/$slug must return an array', + 'prerenderParams for route /posts/$slug must return an array or iterable', ) }) @@ -514,7 +617,7 @@ describe('runPrerenderParams', () => { }) await expect( - runPrerenderParams({ + collectPrerenderParams({ routeTree, pages: [], logger, @@ -535,7 +638,7 @@ describe('runPrerenderParams', () => { }) await expect( - runPrerenderParams({ + collectPrerenderParams({ routeTree, pages: [], logger, @@ -547,7 +650,7 @@ describe('runPrerenderParams', () => { const pages = [{ path: '/about', sitemap: { priority: 0.5 } }] await expect( - runPrerenderParams({ + collectPrerenderParams({ routeTree: undefined, pages, logger, @@ -555,3 +658,117 @@ describe('runPrerenderParams', () => { ).resolves.toEqual(pages) }) }) + +describe('runPrerenderParams streaming', () => { + it('emits generated pages before explicit pages', async () => { + const routeTree = createRouteTree({ + '/posts/$slug': { + prerenderParams: () => [ + { params: { slug: 'first' } }, + { params: { slug: 'second' } }, + ], + }, + }) + + const emitted: Array = [] + await runPrerenderParams({ + routeTree, + pages: [{ path: '/about' }], + logger, + onPage: (page) => { + emitted.push(page.path) + }, + }) + + expect(emitted).toEqual([ + '/posts/first', + '/posts/second', + '/about', + ]) + }) + + it('streams entries from async generators without buffering them all', async () => { + const yields: Array = [] + const routeTree = createRouteTree({ + '/posts/$slug': { + async *prerenderParams() { + for (let i = 0; i < 5; i++) { + yields.push(i) + yield { params: { slug: `post-${i}` } } + } + }, + }, + }) + + const seenAtEmission: Array = [] + await runPrerenderParams({ + routeTree, + pages: [], + logger, + onPage: () => { + seenAtEmission.push(yields.length) + }, + }) + + expect(seenAtEmission).toEqual([1, 2, 3, 4, 5]) + }) + + it('awaits async onPage callbacks before pulling the next entry', async () => { + const order: Array = [] + const routeTree = createRouteTree({ + '/posts/$slug': { + async *prerenderParams() { + order.push('yield-1') + yield { params: { slug: 'first' } } + order.push('yield-2') + yield { params: { slug: 'second' } } + }, + }, + }) + + await runPrerenderParams({ + routeTree, + pages: [], + logger, + onPage: async (page) => { + order.push(`emit-${page.path}`) + await Promise.resolve() + order.push(`done-${page.path}`) + }, + }) + + expect(order).toEqual([ + 'yield-1', + 'emit-/posts/first', + 'done-/posts/first', + 'yield-2', + 'emit-/posts/second', + 'done-/posts/second', + ]) + }) + + it('skips emission for filtered or duplicate generated pages', async () => { + const routeTree = createRouteTree({ + '/posts/$slug': { + prerenderParams: () => [ + { params: { slug: 'keep' } }, + { params: { slug: 'drop' } }, + { params: { slug: 'keep' } }, + ], + }, + }) + + const emitted: Array = [] + await runPrerenderParams({ + routeTree, + pages: [], + logger, + filter: (page) => page.path !== '/posts/drop', + onPage: (page) => { + emitted.push(page) + }, + }) + + expect(emitted.map((page) => page.path)).toEqual(['/posts/keep']) + }) +}) diff --git a/packages/start-plugin-core/tests/prerender-ssrf.test.ts b/packages/start-plugin-core/tests/prerender-ssrf.test.ts index 18c8885dc5..03185dc553 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, validateAndNormalizePrerenderPages } from '../src/prerender' +import { prerender, validateAndNormalizePrerenderPage } from '../src/prerender' vi.mock('../src/utils', async () => { const actual = await vi.importActual('../src/utils') @@ -122,18 +122,14 @@ describe('prerender pages validation', () => { 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', - }, - ], + validateAndNormalizePrerenderPage( + { + 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', - }, - ]) + ).toEqual({ + path: '/posts/대한민국%2Fdocs%3Fdraft%23intro?tag=router+start', + }) }) }) 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 406099fcda..02fb9c4f44 100644 --- a/packages/start-plugin-core/tests/rsbuild-post-build.test.ts +++ b/packages/start-plugin-core/tests/rsbuild-post-build.test.ts @@ -1,6 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { join } from 'pathe' vi.mock('@tanstack/start-server-core', () => ({