diff --git a/.changeset/tall-trees-prerender-params.md b/.changeset/tall-trees-prerender-params.md new file mode 100644 index 00000000000..4af009b383e --- /dev/null +++ b/.changeset/tall-trees-prerender-params.md @@ -0,0 +1,7 @@ +--- +'@tanstack/start-client-core': minor +'@tanstack/start-plugin-core': minor +'@tanstack/start-server-core': minor +--- + +Add typed route `prerenderParams` support for generating dynamic prerender pages at build time. diff --git a/docs/start/framework/react/guide/static-prerendering.md b/docs/start/framework/react/guide/static-prerendering.md index dfaac8fcef5..97f3c5c8824 100644 --- a/docs/start/framework/react/guide/static-prerendering.md +++ b/docs/start/framework/react/guide/static-prerendering.md @@ -46,6 +46,12 @@ 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, + + // Build route options used for prerendering separately from the server bundle + separateRouteOptionsBundle: true, + // Fail if an error occurs during prerendering failOnError: true, @@ -69,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. 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 @@ -81,6 +89,49 @@ Routes are excluded from automatic discovery in the following cases: Note: Dynamic routes can still be prerendered if they are linked from other pages when `crawlLinks` is enabled. +## Dynamic Route Prerendering + +Dynamic routes can declare `prerenderParams` to generate specific parameter values at build time. Each returned entry creates one page from the route path and can override sitemap or prerender options for that page. + +```tsx +// src/routes/posts/$postId.tsx +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts/$postId')({ + validateSearch: (search: Record): { ref?: string } => ({ + ...(typeof search.ref === 'string' ? { ref: search.ref } : {}), + }), + sitemap: { + changefreq: 'weekly', + }, + prerenderParams: async () => { + const posts = await fetchPosts() + + return posts.map((post) => ({ + params: { postId: post.id }, + search: { ref: 'sitemap' }, + sitemap: { + lastmod: post.updatedAt, + priority: 0.8, + }, + })) + }, + component: PostComponent, +}) + +function PostComponent() { + const { postId } = Route.useParams() + + return
Post {postId}
+} +``` + +`prerenderParams` receives `{ routePath, signal }`. The signal aborts when the build process is interrupted and when `prerender.prerenderParamsTimeout` elapses. Each entry's `params` and optional `search` values are typed from the route and used to create the generated URL. Search params are preserved in generated page paths and sitemap URLs using the router's default search serialization; custom `stringifySearch` router options are not applied during this build-time expansion. The route-level `sitemap` option applies to every generated page, and `entry.sitemap` is merged on top for a specific parameter entry. Use `entry.sitemap.exclude` to generate the HTML page without adding it to the sitemap. + +The `sitemap` route option only controls metadata for generated sitemap entries. It does not enable sitemap output by itself; sitemap XML is still controlled by the top-level `sitemap` configuration in your Start plugin config. + +Code that is only referenced by `prerenderParams` or `sitemap` is removed from the client route bundle, so these options can import server-only data sources used to discover pages at build time. + ## Crawling Links When `crawlLinks` is enabled (default: `true`), TanStack Start will extract links from prerendered pages and prerender those linked pages as well. diff --git a/docs/start/framework/solid/guide/static-prerendering.md b/docs/start/framework/solid/guide/static-prerendering.md index 5a98af5e6c5..6f05eeb0b4f 100644 --- a/docs/start/framework/solid/guide/static-prerendering.md +++ b/docs/start/framework/solid/guide/static-prerendering.md @@ -46,6 +46,12 @@ 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, + + // Build route options used for prerendering separately from the server bundle + separateRouteOptionsBundle: true, + // Fail if an error occurs during prerendering failOnError: true, @@ -69,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. 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 @@ -81,6 +89,49 @@ Routes are excluded from automatic discovery in the following cases: Note: Dynamic routes can still be prerendered if they are linked from other pages when `crawlLinks` is enabled. +## Dynamic Route Prerendering + +Dynamic routes can declare `prerenderParams` to generate specific parameter values at build time. Each returned entry creates one page from the route path and can override sitemap or prerender options for that page. + +```tsx +// src/routes/posts/$postId.tsx +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/posts/$postId')({ + validateSearch: (search: Record): { ref?: string } => ({ + ...(typeof search.ref === 'string' ? { ref: search.ref } : {}), + }), + sitemap: { + changefreq: 'weekly', + }, + prerenderParams: async () => { + const posts = await fetchPosts() + + return posts.map((post) => ({ + params: { postId: post.id }, + search: { ref: 'sitemap' }, + sitemap: { + lastmod: post.updatedAt, + priority: 0.8, + }, + })) + }, + component: PostComponent, +}) + +function PostComponent() { + const params = Route.useParams() + + return
Post {params().postId}
+} +``` + +`prerenderParams` receives `{ routePath, signal }`. The signal aborts when the build process is interrupted and when `prerender.prerenderParamsTimeout` elapses. Each entry's `params` and optional `search` values are typed from the route and used to create the generated URL. Search params are preserved in generated page paths and sitemap URLs using the router's default search serialization; custom `stringifySearch` router options are not applied during this build-time expansion. The route-level `sitemap` option applies to every generated page, and `entry.sitemap` is merged on top for a specific parameter entry. Use `entry.sitemap.exclude` to generate the HTML page without adding it to the sitemap. + +The `sitemap` route option only controls metadata for generated sitemap entries. It does not enable sitemap output by itself; sitemap XML is still controlled by the top-level `sitemap` configuration in your Start plugin config. + +Code that is only referenced by `prerenderParams` or `sitemap` is removed from the client route bundle, so these options can import server-only data sources used to discover pages at build time. + ## Crawling Links When `crawlLinks` is enabled (default: `true`), TanStack Start will extract links from prerendered pages and prerender those linked pages as well. diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts index b32796cafc7..58fa35aa07a 100644 --- a/e2e/react-start/basic/src/routeTree.gen.ts +++ b/e2e/react-start/basic/src/routeTree.gen.ts @@ -47,6 +47,7 @@ import { Route as RawStreamSsrMultipleRouteImport } from './routes/raw-stream/ss import { Route as RawStreamSsrMixedRouteImport } from './routes/raw-stream/ssr-mixed' import { Route as RawStreamSsrBinaryHintRouteImport } from './routes/raw-stream/ssr-binary-hint' import { Route as RawStreamClientCallRouteImport } from './routes/raw-stream/client-call' +import { Route as PrerenderParamsSlugRouteImport } from './routes/prerender-params.$slug' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' import { Route as NotFoundViaLoaderRouteImport } from './routes/not-found/via-loader' import { Route as NotFoundViaBeforeLoadTargetRootRouteImport } from './routes/not-found/via-beforeLoad-target-root' @@ -75,6 +76,7 @@ import { Route as RedirectTargetServerFnViaUseServerFnRouteImport } from './rout import { Route as RedirectTargetServerFnViaLoaderRouteImport } from './routes/redirect/$target/serverFn/via-loader' import { Route as RedirectTargetServerFnViaBeforeLoadRouteImport } from './routes/redirect/$target/serverFn/via-beforeLoad' import { Route as FooBarQuxHereRouteImport } from './routes/foo/$bar/$qux/_here' +import { Route as LayoutLayout2PrerenderNestedSlugRouteImport } from './routes/_layout/_layout-2/prerender-nested.$slug' import { Route as NotFoundDeepBCRouteRouteImport } from './routes/not-found/deep/b/c/route' import { Route as FooBarQuxHereIndexRouteImport } from './routes/foo/$bar/$qux/_here/index' import { Route as NotFoundDeepBCDRouteImport } from './routes/not-found/deep/b/c/d' @@ -271,6 +273,11 @@ const RawStreamClientCallRoute = RawStreamClientCallRouteImport.update({ path: '/client-call', getParentRoute: () => RawStreamRoute, } as any) +const PrerenderParamsSlugRoute = PrerenderParamsSlugRouteImport.update({ + id: '/prerender-params/$slug', + path: '/prerender-params/$slug', + getParentRoute: () => rootRouteImport, +} as any) const PostsPostIdRoute = PostsPostIdRouteImport.update({ id: '/$postId', path: '/$postId', @@ -423,6 +430,12 @@ const FooBarQuxHereRoute = FooBarQuxHereRouteImport.update({ path: '/foo/$bar/$qux', getParentRoute: () => rootRouteImport, } as any) +const LayoutLayout2PrerenderNestedSlugRoute = + LayoutLayout2PrerenderNestedSlugRouteImport.update({ + id: '/prerender-nested/$slug', + path: '/prerender-nested/$slug', + getParentRoute: () => LayoutLayout2Route, + } as any) const NotFoundDeepBCRouteRoute = NotFoundDeepBCRouteRouteImport.update({ id: '/c', path: '/c', @@ -465,6 +478,7 @@ export interface FileRoutesByFullPath { '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/prerender-params/$slug': typeof PrerenderParamsSlugRoute '/raw-stream/client-call': typeof RawStreamClientCallRoute '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute @@ -500,6 +514,7 @@ export interface FileRoutesByFullPath { '/not-found/parent-boundary/': typeof NotFoundParentBoundaryIndexRoute '/redirect/$target/': typeof RedirectTargetIndexRoute '/not-found/deep/b/c': typeof NotFoundDeepBCRouteRouteWithChildren + '/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute '/foo/$bar/$qux': typeof FooBarQuxHereRouteWithChildren '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute @@ -527,6 +542,7 @@ export interface FileRoutesByTo { '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/prerender-params/$slug': typeof PrerenderParamsSlugRoute '/raw-stream/client-call': typeof RawStreamClientCallRoute '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute @@ -561,6 +577,7 @@ export interface FileRoutesByTo { '/not-found/parent-boundary': typeof NotFoundParentBoundaryIndexRoute '/redirect/$target': typeof RedirectTargetIndexRoute '/not-found/deep/b/c': typeof NotFoundDeepBCRouteRouteWithChildren + '/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute '/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute @@ -597,6 +614,7 @@ export interface FileRoutesById { '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/prerender-params/$slug': typeof PrerenderParamsSlugRoute '/raw-stream/client-call': typeof RawStreamClientCallRoute '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute @@ -632,6 +650,7 @@ export interface FileRoutesById { '/not-found/parent-boundary/': typeof NotFoundParentBoundaryIndexRoute '/redirect/$target/': typeof RedirectTargetIndexRoute '/not-found/deep/b/c': typeof NotFoundDeepBCRouteRouteWithChildren + '/_layout/_layout-2/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute '/foo/$bar/$qux/_here': typeof FooBarQuxHereRouteWithChildren '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute @@ -668,6 +687,7 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad-target-root' | '/not-found/via-loader' | '/posts/$postId' + | '/prerender-params/$slug' | '/raw-stream/client-call' | '/raw-stream/ssr-binary-hint' | '/raw-stream/ssr-mixed' @@ -703,6 +723,7 @@ export interface FileRouteTypes { | '/not-found/parent-boundary/' | '/redirect/$target/' | '/not-found/deep/b/c' + | '/prerender-nested/$slug' | '/foo/$bar/$qux' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' @@ -730,6 +751,7 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad-target-root' | '/not-found/via-loader' | '/posts/$postId' + | '/prerender-params/$slug' | '/raw-stream/client-call' | '/raw-stream/ssr-binary-hint' | '/raw-stream/ssr-mixed' @@ -764,6 +786,7 @@ export interface FileRouteTypes { | '/not-found/parent-boundary' | '/redirect/$target' | '/not-found/deep/b/c' + | '/prerender-nested/$slug' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' | '/redirect/$target/serverFn/via-useServerFn' @@ -799,6 +822,7 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad-target-root' | '/not-found/via-loader' | '/posts/$postId' + | '/prerender-params/$slug' | '/raw-stream/client-call' | '/raw-stream/ssr-binary-hint' | '/raw-stream/ssr-mixed' @@ -834,6 +858,7 @@ export interface FileRouteTypes { | '/not-found/parent-boundary/' | '/redirect/$target/' | '/not-found/deep/b/c' + | '/_layout/_layout-2/prerender-nested/$slug' | '/foo/$bar/$qux/_here' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' @@ -863,6 +888,7 @@ export interface RootRouteChildren { UsersRoute: typeof UsersRouteWithChildren ApiUsersRoute: typeof ApiUsersRouteWithChildren MultiCookieRedirectTargetRoute: typeof MultiCookieRedirectTargetRoute + PrerenderParamsSlugRoute: typeof PrerenderParamsSlugRoute RedirectTargetRoute: typeof RedirectTargetRouteWithChildren MultiCookieRedirectIndexRoute: typeof MultiCookieRedirectIndexRoute RedirectIndexRoute: typeof RedirectIndexRoute @@ -1138,6 +1164,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RawStreamClientCallRouteImport parentRoute: typeof RawStreamRoute } + '/prerender-params/$slug': { + id: '/prerender-params/$slug' + path: '/prerender-params/$slug' + fullPath: '/prerender-params/$slug' + preLoaderRoute: typeof PrerenderParamsSlugRouteImport + parentRoute: typeof rootRouteImport + } '/posts/$postId': { id: '/posts/$postId' path: '/$postId' @@ -1334,6 +1367,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof FooBarQuxHereRouteImport parentRoute: typeof rootRouteImport } + '/_layout/_layout-2/prerender-nested/$slug': { + id: '/_layout/_layout-2/prerender-nested/$slug' + path: '/prerender-nested/$slug' + fullPath: '/prerender-nested/$slug' + preLoaderRoute: typeof LayoutLayout2PrerenderNestedSlugRouteImport + parentRoute: typeof LayoutLayout2Route + } '/not-found/deep/b/c': { id: '/not-found/deep/b/c' path: '/c' @@ -1487,11 +1527,13 @@ const SpecialCharsRouteRouteWithChildren = interface LayoutLayout2RouteChildren { LayoutLayout2LayoutARoute: typeof LayoutLayout2LayoutARoute LayoutLayout2LayoutBRoute: typeof LayoutLayout2LayoutBRoute + LayoutLayout2PrerenderNestedSlugRoute: typeof LayoutLayout2PrerenderNestedSlugRoute } const LayoutLayout2RouteChildren: LayoutLayout2RouteChildren = { LayoutLayout2LayoutARoute: LayoutLayout2LayoutARoute, LayoutLayout2LayoutBRoute: LayoutLayout2LayoutBRoute, + LayoutLayout2PrerenderNestedSlugRoute: LayoutLayout2PrerenderNestedSlugRoute, } const LayoutLayout2RouteWithChildren = LayoutLayout2Route._addFileChildren( @@ -1627,6 +1669,7 @@ const rootRouteChildren: RootRouteChildren = { UsersRoute: UsersRouteWithChildren, ApiUsersRoute: ApiUsersRouteWithChildren, MultiCookieRedirectTargetRoute: MultiCookieRedirectTargetRoute, + PrerenderParamsSlugRoute: PrerenderParamsSlugRoute, RedirectTargetRoute: RedirectTargetRouteWithChildren, MultiCookieRedirectIndexRoute: MultiCookieRedirectIndexRoute, RedirectIndexRoute: RedirectIndexRoute, diff --git a/e2e/react-start/basic/src/routes/-prerender-params.server.ts b/e2e/react-start/basic/src/routes/-prerender-params.server.ts new file mode 100644 index 00000000000..5593659c824 --- /dev/null +++ b/e2e/react-start/basic/src/routes/-prerender-params.server.ts @@ -0,0 +1,11 @@ +import '@tanstack/react-start/server-only' + +export const SERVER_ONLY_PRERENDER_MARKER = + 'server-only-prerender-marker-should-not-be-in-client' + +export function getServerOnlyPrerenderSlug() { + return SERVER_ONLY_PRERENDER_MARKER.replace( + 'server-only-prerender-marker-should-not-be-in-client', + 'server-only-slug', + ) +} diff --git a/e2e/react-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx b/e2e/react-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx new file mode 100644 index 00000000000..3e4094ce8f3 --- /dev/null +++ b/e2e/react-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute( + '/_layout/_layout-2/prerender-nested/$slug', +)({ + prerenderParams: () => [{ params: { slug: 'under-layout' } }], + component: RouteComponent, +}) + +function RouteComponent() { + const params = Route.useParams() + + return
Nested prerendered slug: {params.slug}
+} diff --git a/e2e/react-start/basic/src/routes/prerender-params.$slug.tsx b/e2e/react-start/basic/src/routes/prerender-params.$slug.tsx new file mode 100644 index 00000000000..a5f9a31a2af --- /dev/null +++ b/e2e/react-start/basic/src/routes/prerender-params.$slug.tsx @@ -0,0 +1,103 @@ +import { createFileRoute } from '@tanstack/react-router' +import z from 'zod' +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({ + 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, + }, + }, + { + 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, +}) + +function RouteComponent() { + const { slug } = Route.useParams() + const search = Route.useSearch() + + return ( +
+ Prerendered slug: {slug}. Search page: {search.page ?? 'none'}. Search + tag: {search.tag ?? 'none'} +
+ ) +} diff --git a/e2e/react-start/basic/start-mode-config.ts b/e2e/react-start/basic/start-mode-config.ts index 3cb6db26586..3c3f2147573 100644 --- a/e2e/react-start/basic/start-mode-config.ts +++ b/e2e/react-start/basic/start-mode-config.ts @@ -28,5 +28,11 @@ export function getStartModeConfig() { maxRedirects: 100, } : undefined, + sitemap: isPrerender + ? { + enabled: true, + host: 'https://example.com', + } + : undefined, } } diff --git a/e2e/react-start/basic/tests/prerendering.spec.ts b/e2e/react-start/basic/tests/prerendering.spec.ts index ddcf720a4c0..d873d722452 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' @@ -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') || filePath.endsWith('.mjs')) && + readFileSync(filePath, 'utf-8').includes(marker) + ) + }) +} test.describe('Prerender Static Path Discovery', () => { test.skip(!isPrerender, 'Skipping since not in prerender mode') @@ -44,5 +70,153 @@ 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).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', () => { + const htmlPath = join( + distDir, + 'prerender-params/server-only-slug/index.html', + ) + + expect(existsSync(htmlPath)).toBe(true) + expect( + 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') + + expect(existsSync(sitemapPath)).toBe(true) + + const sitemap = readFileSync(sitemapPath, 'utf-8') + expect(sitemap).toContain( + 'https://example.com/prerender-params/hello-world', + ) + expect(sitemap).toContain('2026-05-05') + expect(sitemap).toContain('0.8') + expect(sitemap).toContain('weekly') + expect(sitemap).toContain( + 'https://example.com/prerender-params/대한민국', + ) + expect(sitemap).toContain('0.6') + expect(sitemap).toContain( + 'https://example.com/prerender-params/with-query?page=2&tag=router+start', + ) + expect(sitemap).toContain('0.4') + expect(sitemap).not.toContain( + 'https://example.com/prerender-params/server-only-slug', + ) + }) }) }) diff --git a/e2e/solid-start/basic/package.json b/e2e/solid-start/basic/package.json index 18089a33a5d..8a2bf0ffae0 100644 --- a/e2e/solid-start/basic/package.json +++ b/e2e/solid-start/basic/package.json @@ -65,6 +65,10 @@ { "toolchain": "rsbuild", "mode": "ssr" + }, + { + "toolchain": "rsbuild", + "mode": "prerender" } ] } diff --git a/e2e/solid-start/basic/rsbuild.config.ts b/e2e/solid-start/basic/rsbuild.config.ts index ea04a3167aa..cdca4222ea3 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 === p || page.path.startsWith(`${p}/`)), + maxRedirects: 100, +} const outDir = process.env.E2E_DIST_DIR ?? 'dist' @@ -11,7 +30,15 @@ export default defineConfig({ include: /\.(?:jsx|tsx)$/, }), pluginSolid(), - tanstackStart(), + tanstackStart({ + prerender: isPrerender ? prerenderConfiguration : undefined, + sitemap: isPrerender + ? { + enabled: true, + host: 'https://example.com', + } + : undefined, + }), ], output: { distPath: { diff --git a/e2e/solid-start/basic/src/routeTree.gen.ts b/e2e/solid-start/basic/src/routeTree.gen.ts index c9fba32156b..b9d5d042105 100644 --- a/e2e/solid-start/basic/src/routeTree.gen.ts +++ b/e2e/solid-start/basic/src/routeTree.gen.ts @@ -44,6 +44,7 @@ import { Route as RawStreamSsrMultipleRouteImport } from './routes/raw-stream/ss import { Route as RawStreamSsrMixedRouteImport } from './routes/raw-stream/ssr-mixed' import { Route as RawStreamSsrBinaryHintRouteImport } from './routes/raw-stream/ssr-binary-hint' import { Route as RawStreamClientCallRouteImport } from './routes/raw-stream/client-call' +import { Route as PrerenderParamsSlugRouteImport } from './routes/prerender-params.$slug' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' import { Route as NotFoundViaLoaderRouteImport } from './routes/not-found/via-loader' import { Route as NotFoundViaBeforeLoadTargetRootRouteImport } from './routes/not-found/via-beforeLoad-target-root' @@ -69,6 +70,7 @@ import { Route as RedirectTargetServerFnIndexRouteImport } from './routes/redire import { Route as RedirectTargetServerFnViaUseServerFnRouteImport } from './routes/redirect/$target/serverFn/via-useServerFn' import { Route as RedirectTargetServerFnViaLoaderRouteImport } from './routes/redirect/$target/serverFn/via-loader' import { Route as RedirectTargetServerFnViaBeforeLoadRouteImport } from './routes/redirect/$target/serverFn/via-beforeLoad' +import { Route as LayoutLayout2PrerenderNestedSlugRouteImport } from './routes/_layout/_layout-2/prerender-nested.$slug' const UsersRoute = UsersRouteImport.update({ id: '/users', @@ -247,6 +249,11 @@ const RawStreamClientCallRoute = RawStreamClientCallRouteImport.update({ path: '/client-call', getParentRoute: () => RawStreamRoute, } as any) +const PrerenderParamsSlugRoute = PrerenderParamsSlugRouteImport.update({ + id: '/prerender-params/$slug', + path: '/prerender-params/$slug', + getParentRoute: () => rootRouteImport, +} as any) const PostsPostIdRoute = PostsPostIdRouteImport.update({ id: '/$postId', path: '/$postId', @@ -385,6 +392,12 @@ const RedirectTargetServerFnViaBeforeLoadRoute = path: '/serverFn/via-beforeLoad', getParentRoute: () => RedirectTargetRoute, } as any) +const LayoutLayout2PrerenderNestedSlugRoute = + LayoutLayout2PrerenderNestedSlugRouteImport.update({ + id: '/prerender-nested/$slug', + path: '/prerender-nested/$slug', + getParentRoute: () => LayoutLayout2Route, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -408,6 +421,7 @@ export interface FileRoutesByFullPath { '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/prerender-params/$slug': typeof PrerenderParamsSlugRoute '/raw-stream/client-call': typeof RawStreamClientCallRoute '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute @@ -441,6 +455,7 @@ export interface FileRoutesByFullPath { '/transition/count/create-resource': typeof TransitionCountCreateResourceRoute '/transition/typing/create-resource': typeof TransitionTypingCreateResourceRoute '/redirect/$target/': typeof RedirectTargetIndexRoute + '/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute '/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute @@ -463,6 +478,7 @@ export interface FileRoutesByTo { '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/prerender-params/$slug': typeof PrerenderParamsSlugRoute '/raw-stream/client-call': typeof RawStreamClientCallRoute '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute @@ -495,6 +511,7 @@ export interface FileRoutesByTo { '/transition/count/create-resource': typeof TransitionCountCreateResourceRoute '/transition/typing/create-resource': typeof TransitionTypingCreateResourceRoute '/redirect/$target': typeof RedirectTargetIndexRoute + '/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute '/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute @@ -525,6 +542,7 @@ export interface FileRoutesById { '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/prerender-params/$slug': typeof PrerenderParamsSlugRoute '/raw-stream/client-call': typeof RawStreamClientCallRoute '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute @@ -558,6 +576,7 @@ export interface FileRoutesById { '/transition/count/create-resource': typeof TransitionCountCreateResourceRoute '/transition/typing/create-resource': typeof TransitionTypingCreateResourceRoute '/redirect/$target/': typeof RedirectTargetIndexRoute + '/_layout/_layout-2/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute '/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute @@ -587,6 +606,7 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad-target-root' | '/not-found/via-loader' | '/posts/$postId' + | '/prerender-params/$slug' | '/raw-stream/client-call' | '/raw-stream/ssr-binary-hint' | '/raw-stream/ssr-mixed' @@ -620,6 +640,7 @@ export interface FileRouteTypes { | '/transition/count/create-resource' | '/transition/typing/create-resource' | '/redirect/$target/' + | '/prerender-nested/$slug' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' | '/redirect/$target/serverFn/via-useServerFn' @@ -642,6 +663,7 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad-target-root' | '/not-found/via-loader' | '/posts/$postId' + | '/prerender-params/$slug' | '/raw-stream/client-call' | '/raw-stream/ssr-binary-hint' | '/raw-stream/ssr-mixed' @@ -674,6 +696,7 @@ export interface FileRouteTypes { | '/transition/count/create-resource' | '/transition/typing/create-resource' | '/redirect/$target' + | '/prerender-nested/$slug' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' | '/redirect/$target/serverFn/via-useServerFn' @@ -703,6 +726,7 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad-target-root' | '/not-found/via-loader' | '/posts/$postId' + | '/prerender-params/$slug' | '/raw-stream/client-call' | '/raw-stream/ssr-binary-hint' | '/raw-stream/ssr-mixed' @@ -736,6 +760,7 @@ export interface FileRouteTypes { | '/transition/count/create-resource' | '/transition/typing/create-resource' | '/redirect/$target/' + | '/_layout/_layout-2/prerender-nested/$slug' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' | '/redirect/$target/serverFn/via-useServerFn' @@ -759,6 +784,7 @@ export interface RootRouteChildren { UsersRoute: typeof UsersRouteWithChildren ApiUsersRoute: typeof ApiUsersRouteWithChildren MultiCookieRedirectTargetRoute: typeof MultiCookieRedirectTargetRoute + PrerenderParamsSlugRoute: typeof PrerenderParamsSlugRoute RedirectTargetRoute: typeof RedirectTargetRouteWithChildren MultiCookieRedirectIndexRoute: typeof MultiCookieRedirectIndexRoute RedirectIndexRoute: typeof RedirectIndexRoute @@ -1014,6 +1040,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof RawStreamClientCallRouteImport parentRoute: typeof RawStreamRoute } + '/prerender-params/$slug': { + id: '/prerender-params/$slug' + path: '/prerender-params/$slug' + fullPath: '/prerender-params/$slug' + preLoaderRoute: typeof PrerenderParamsSlugRouteImport + parentRoute: typeof rootRouteImport + } '/posts/$postId': { id: '/posts/$postId' path: '/$postId' @@ -1189,6 +1222,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof RedirectTargetServerFnViaBeforeLoadRouteImport parentRoute: typeof RedirectTargetRoute } + '/_layout/_layout-2/prerender-nested/$slug': { + id: '/_layout/_layout-2/prerender-nested/$slug' + path: '/prerender-nested/$slug' + fullPath: '/prerender-nested/$slug' + preLoaderRoute: typeof LayoutLayout2PrerenderNestedSlugRouteImport + parentRoute: typeof LayoutLayout2Route + } } } @@ -1282,11 +1322,13 @@ const SpecialCharsRouteRouteWithChildren = interface LayoutLayout2RouteChildren { LayoutLayout2LayoutARoute: typeof LayoutLayout2LayoutARoute LayoutLayout2LayoutBRoute: typeof LayoutLayout2LayoutBRoute + LayoutLayout2PrerenderNestedSlugRoute: typeof LayoutLayout2PrerenderNestedSlugRoute } const LayoutLayout2RouteChildren: LayoutLayout2RouteChildren = { LayoutLayout2LayoutARoute: LayoutLayout2LayoutARoute, LayoutLayout2LayoutBRoute: LayoutLayout2LayoutBRoute, + LayoutLayout2PrerenderNestedSlugRoute: LayoutLayout2PrerenderNestedSlugRoute, } const LayoutLayout2RouteWithChildren = LayoutLayout2Route._addFileChildren( @@ -1407,6 +1449,7 @@ const rootRouteChildren: RootRouteChildren = { UsersRoute: UsersRouteWithChildren, ApiUsersRoute: ApiUsersRouteWithChildren, MultiCookieRedirectTargetRoute: MultiCookieRedirectTargetRoute, + PrerenderParamsSlugRoute: PrerenderParamsSlugRoute, RedirectTargetRoute: RedirectTargetRouteWithChildren, MultiCookieRedirectIndexRoute: MultiCookieRedirectIndexRoute, RedirectIndexRoute: RedirectIndexRoute, diff --git a/e2e/solid-start/basic/src/routes/-prerender-params.server.ts b/e2e/solid-start/basic/src/routes/-prerender-params.server.ts new file mode 100644 index 00000000000..f0a0f792316 --- /dev/null +++ b/e2e/solid-start/basic/src/routes/-prerender-params.server.ts @@ -0,0 +1,11 @@ +import '@tanstack/solid-start/server-only' + +export const serverOnlyPrerenderMarker = + 'server-only-prerender-marker-should-not-be-in-client' + +export function getServerOnlyPrerenderSlug() { + return serverOnlyPrerenderMarker.replace( + 'server-only-prerender-marker-should-not-be-in-client', + 'server-only-slug', + ) +} diff --git a/e2e/solid-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx b/e2e/solid-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx new file mode 100644 index 00000000000..70b3b4ade8a --- /dev/null +++ b/e2e/solid-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute( + '/_layout/_layout-2/prerender-nested/$slug', +)({ + prerenderParams: () => [{ params: { slug: 'under-layout' } }], + component: RouteComponent, +}) + +function RouteComponent() { + const params = Route.useParams() + + return
Nested prerendered slug: {params().slug}
+} diff --git a/e2e/solid-start/basic/src/routes/prerender-params.$slug.tsx b/e2e/solid-start/basic/src/routes/prerender-params.$slug.tsx new file mode 100644 index 00000000000..1bd2d4060a7 --- /dev/null +++ b/e2e/solid-start/basic/src/routes/prerender-params.$slug.tsx @@ -0,0 +1,60 @@ +import { createFileRoute } from '@tanstack/solid-router' +import z from 'zod' +import { getServerOnlyPrerenderSlug } from './-prerender-params.server' + +export const Route = createFileRoute('/prerender-params/$slug')({ + validateSearch: z.object({ + page: z.number().optional(), + tag: z.string().optional(), + }), + sitemap: { + changefreq: 'weekly', + }, + prerenderParams: () => [ + { + params: { slug: 'hello-world' }, + sitemap: { + lastmod: '2026-05-05', + priority: 0.8, + }, + }, + { + params: { slug: '대한민국' }, + sitemap: { + priority: 0.6, + }, + }, + { + params: { slug: 'reserved?hash#plus+' }, + sitemap: { + exclude: true, + }, + }, + { + params: { slug: 'with-query' }, + search: { page: 2, tag: 'router start' }, + sitemap: { + priority: 0.4, + }, + }, + { + params: { slug: getServerOnlyPrerenderSlug() }, + sitemap: { + exclude: true, + }, + }, + ], + component: RouteComponent, +}) + +function RouteComponent() { + const params = Route.useParams() + const search = Route.useSearch() + + return ( +
+ Prerendered slug: {params().slug}. Search page: {search().page ?? 'none'}. + Search tag: {search().tag ?? 'none'} +
+ ) +} diff --git a/e2e/solid-start/basic/tests/prerendering.spec.ts b/e2e/solid-start/basic/tests/prerendering.spec.ts index ddcf720a4c0..c256c0d6007 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,125 @@ 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).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', () => { + const htmlPath = join( + distDir, + 'prerender-params/server-only-slug/index.html', + ) + + expect(existsSync(htmlPath)).toBe(true) + expect( + readdirSync(distDir, { recursive: true }).some((relativePath) => { + const filePath = join(distDir, String(relativePath)) + return ( + statSync(filePath).isFile() && + readFileSync(filePath, 'utf-8').includes( + 'server-only-prerender-marker', + ) + ) + }), + ).toBe(false) + }) + + test('should include route sitemap options from prerenderParams', () => { + const sitemapPath = join(distDir, 'sitemap.xml') + + test.skip(!existsSync(sitemapPath), 'Skipping since sitemap is disabled') + expect(existsSync(sitemapPath)).toBe(true) + + const sitemap = readFileSync(sitemapPath, 'utf-8') + expect(sitemap).toContain( + 'https://example.com/prerender-params/hello-world', + ) + expect(sitemap).toContain('2026-05-05') + expect(sitemap).toContain('0.8') + expect(sitemap).toContain('weekly') + expect(sitemap).toContain( + 'https://example.com/prerender-params/대한민국', + ) + expect(sitemap).toContain('0.6') + expect(sitemap).toContain( + 'https://example.com/prerender-params/with-query?page=2&tag=router+start', + ) + expect(sitemap).toContain('0.4') + expect(sitemap).not.toContain( + 'https://example.com/prerender-params/server-only-slug', + ) + }) }) }) diff --git a/e2e/solid-start/basic/vite.config.ts b/e2e/solid-start/basic/vite.config.ts index 9e26483d040..c8128fb92f5 100644 --- a/e2e/solid-start/basic/vite.config.ts +++ b/e2e/solid-start/basic/vite.config.ts @@ -45,6 +45,12 @@ export default defineConfig({ tanstackStart({ spa: isSpaMode ? spaModeConfiguration : undefined, prerender: isPrerender ? prerenderConfiguration : undefined, + sitemap: isPrerender + ? { + enabled: true, + host: 'https://example.com', + } + : undefined, }), viteSolid({ ssr: true }), ], diff --git a/e2e/vue-start/basic/package.json b/e2e/vue-start/basic/package.json index e1fc89db169..900ace5860b 100644 --- a/e2e/vue-start/basic/package.json +++ b/e2e/vue-start/basic/package.json @@ -67,6 +67,10 @@ { "toolchain": "rsbuild", "mode": "ssr" + }, + { + "toolchain": "rsbuild", + "mode": "prerender" } ] } diff --git a/e2e/vue-start/basic/rsbuild.config.ts b/e2e/vue-start/basic/rsbuild.config.ts index 93af798c3c6..14af169e517 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 === p || page.path.startsWith(`${p}/`)), + maxRedirects: 100, +} + const outDir = process.env.E2E_DIST_DIR ?? 'dist' export default defineConfig({ @@ -14,7 +32,15 @@ export default defineConfig({ }), pluginVue(), pluginVueJsx(), - tanstackStart(), + tanstackStart({ + prerender: isPrerender ? prerenderConfiguration : undefined, + sitemap: isPrerender + ? { + enabled: true, + host: 'https://example.com', + } + : undefined, + }), ], performance: { chunkSplit: { diff --git a/e2e/vue-start/basic/src/routeTree.gen.ts b/e2e/vue-start/basic/src/routeTree.gen.ts index 016a292835b..bd680185e89 100644 --- a/e2e/vue-start/basic/src/routeTree.gen.ts +++ b/e2e/vue-start/basic/src/routeTree.gen.ts @@ -43,6 +43,7 @@ import { Route as RawStreamSsrMultipleRouteImport } from './routes/raw-stream/ss import { Route as RawStreamSsrMixedRouteImport } from './routes/raw-stream/ssr-mixed' import { Route as RawStreamSsrBinaryHintRouteImport } from './routes/raw-stream/ssr-binary-hint' import { Route as RawStreamClientCallRouteImport } from './routes/raw-stream/client-call' +import { Route as PrerenderParamsSlugRouteImport } from './routes/prerender-params.$slug' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' import { Route as NotFoundViaLoaderRouteImport } from './routes/not-found/via-loader' import { Route as NotFoundViaBeforeLoadTargetRootRouteImport } from './routes/not-found/via-beforeLoad-target-root' @@ -66,6 +67,7 @@ import { Route as RedirectTargetServerFnIndexRouteImport } from './routes/redire import { Route as RedirectTargetServerFnViaUseServerFnRouteImport } from './routes/redirect/$target/serverFn/via-useServerFn' import { Route as RedirectTargetServerFnViaLoaderRouteImport } from './routes/redirect/$target/serverFn/via-loader' import { Route as RedirectTargetServerFnViaBeforeLoadRouteImport } from './routes/redirect/$target/serverFn/via-beforeLoad' +import { Route as LayoutLayout2PrerenderNestedSlugRouteImport } from './routes/_layout/_layout-2/prerender-nested.$slug' const UsersRoute = UsersRouteImport.update({ id: '/users', @@ -239,6 +241,11 @@ const RawStreamClientCallRoute = RawStreamClientCallRouteImport.update({ path: '/client-call', getParentRoute: () => RawStreamRoute, } as any) +const PrerenderParamsSlugRoute = PrerenderParamsSlugRouteImport.update({ + id: '/prerender-params/$slug', + path: '/prerender-params/$slug', + getParentRoute: () => rootRouteImport, +} as any) const PostsPostIdRoute = PostsPostIdRouteImport.update({ id: '/$postId', path: '/$postId', @@ -365,6 +372,12 @@ const RedirectTargetServerFnViaBeforeLoadRoute = path: '/serverFn/via-beforeLoad', getParentRoute: () => RedirectTargetRoute, } as any) +const LayoutLayout2PrerenderNestedSlugRoute = + LayoutLayout2PrerenderNestedSlugRouteImport.update({ + id: '/prerender-nested/$slug', + path: '/prerender-nested/$slug', + getParentRoute: () => LayoutLayout2Route, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -387,6 +400,7 @@ export interface FileRoutesByFullPath { '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/prerender-params/$slug': typeof PrerenderParamsSlugRoute '/raw-stream/client-call': typeof RawStreamClientCallRoute '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute @@ -418,6 +432,7 @@ export interface FileRoutesByFullPath { '/specialChars/malformed/$param': typeof SpecialCharsMalformedParamRoute '/specialChars/malformed/search': typeof SpecialCharsMalformedSearchRoute '/redirect/$target/': typeof RedirectTargetIndexRoute + '/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute '/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute @@ -439,6 +454,7 @@ export interface FileRoutesByTo { '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/prerender-params/$slug': typeof PrerenderParamsSlugRoute '/raw-stream/client-call': typeof RawStreamClientCallRoute '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute @@ -469,6 +485,7 @@ export interface FileRoutesByTo { '/specialChars/malformed/$param': typeof SpecialCharsMalformedParamRoute '/specialChars/malformed/search': typeof SpecialCharsMalformedSearchRoute '/redirect/$target': typeof RedirectTargetIndexRoute + '/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute '/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute @@ -498,6 +515,7 @@ export interface FileRoutesById { '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/prerender-params/$slug': typeof PrerenderParamsSlugRoute '/raw-stream/client-call': typeof RawStreamClientCallRoute '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute @@ -529,6 +547,7 @@ export interface FileRoutesById { '/specialChars/malformed/$param': typeof SpecialCharsMalformedParamRoute '/specialChars/malformed/search': typeof SpecialCharsMalformedSearchRoute '/redirect/$target/': typeof RedirectTargetIndexRoute + '/_layout/_layout-2/prerender-nested/$slug': typeof LayoutLayout2PrerenderNestedSlugRoute '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute '/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute @@ -557,6 +576,7 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad-target-root' | '/not-found/via-loader' | '/posts/$postId' + | '/prerender-params/$slug' | '/raw-stream/client-call' | '/raw-stream/ssr-binary-hint' | '/raw-stream/ssr-mixed' @@ -588,6 +608,7 @@ export interface FileRouteTypes { | '/specialChars/malformed/$param' | '/specialChars/malformed/search' | '/redirect/$target/' + | '/prerender-nested/$slug' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' | '/redirect/$target/serverFn/via-useServerFn' @@ -609,6 +630,7 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad-target-root' | '/not-found/via-loader' | '/posts/$postId' + | '/prerender-params/$slug' | '/raw-stream/client-call' | '/raw-stream/ssr-binary-hint' | '/raw-stream/ssr-mixed' @@ -639,6 +661,7 @@ export interface FileRouteTypes { | '/specialChars/malformed/$param' | '/specialChars/malformed/search' | '/redirect/$target' + | '/prerender-nested/$slug' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' | '/redirect/$target/serverFn/via-useServerFn' @@ -667,6 +690,7 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad-target-root' | '/not-found/via-loader' | '/posts/$postId' + | '/prerender-params/$slug' | '/raw-stream/client-call' | '/raw-stream/ssr-binary-hint' | '/raw-stream/ssr-mixed' @@ -698,6 +722,7 @@ export interface FileRouteTypes { | '/specialChars/malformed/$param' | '/specialChars/malformed/search' | '/redirect/$target/' + | '/_layout/_layout-2/prerender-nested/$slug' | '/redirect/$target/serverFn/via-beforeLoad' | '/redirect/$target/serverFn/via-loader' | '/redirect/$target/serverFn/via-useServerFn' @@ -720,6 +745,7 @@ export interface RootRouteChildren { UsersRoute: typeof UsersRouteWithChildren ApiUsersRoute: typeof ApiUsersRouteWithChildren MultiCookieRedirectTargetRoute: typeof MultiCookieRedirectTargetRoute + PrerenderParamsSlugRoute: typeof PrerenderParamsSlugRoute RedirectTargetRoute: typeof RedirectTargetRouteWithChildren MultiCookieRedirectIndexRoute: typeof MultiCookieRedirectIndexRoute RedirectIndexRoute: typeof RedirectIndexRoute @@ -966,6 +992,13 @@ declare module '@tanstack/vue-router' { preLoaderRoute: typeof RawStreamClientCallRouteImport parentRoute: typeof RawStreamRoute } + '/prerender-params/$slug': { + id: '/prerender-params/$slug' + path: '/prerender-params/$slug' + fullPath: '/prerender-params/$slug' + preLoaderRoute: typeof PrerenderParamsSlugRouteImport + parentRoute: typeof rootRouteImport + } '/posts/$postId': { id: '/posts/$postId' path: '/$postId' @@ -1127,6 +1160,13 @@ declare module '@tanstack/vue-router' { preLoaderRoute: typeof RedirectTargetServerFnViaBeforeLoadRouteImport parentRoute: typeof RedirectTargetRoute } + '/_layout/_layout-2/prerender-nested/$slug': { + id: '/_layout/_layout-2/prerender-nested/$slug' + path: '/prerender-nested/$slug' + fullPath: '/prerender-nested/$slug' + preLoaderRoute: typeof LayoutLayout2PrerenderNestedSlugRouteImport + parentRoute: typeof LayoutLayout2Route + } } } @@ -1220,11 +1260,13 @@ const SpecialCharsRouteRouteWithChildren = interface LayoutLayout2RouteChildren { LayoutLayout2LayoutARoute: typeof LayoutLayout2LayoutARoute LayoutLayout2LayoutBRoute: typeof LayoutLayout2LayoutBRoute + LayoutLayout2PrerenderNestedSlugRoute: typeof LayoutLayout2PrerenderNestedSlugRoute } const LayoutLayout2RouteChildren: LayoutLayout2RouteChildren = { LayoutLayout2LayoutARoute: LayoutLayout2LayoutARoute, LayoutLayout2LayoutBRoute: LayoutLayout2LayoutBRoute, + LayoutLayout2PrerenderNestedSlugRoute: LayoutLayout2PrerenderNestedSlugRoute, } const LayoutLayout2RouteWithChildren = LayoutLayout2Route._addFileChildren( @@ -1344,6 +1386,7 @@ const rootRouteChildren: RootRouteChildren = { UsersRoute: UsersRouteWithChildren, ApiUsersRoute: ApiUsersRouteWithChildren, MultiCookieRedirectTargetRoute: MultiCookieRedirectTargetRoute, + PrerenderParamsSlugRoute: PrerenderParamsSlugRoute, RedirectTargetRoute: RedirectTargetRouteWithChildren, MultiCookieRedirectIndexRoute: MultiCookieRedirectIndexRoute, RedirectIndexRoute: RedirectIndexRoute, diff --git a/e2e/vue-start/basic/src/routes/-prerender-params.server.ts b/e2e/vue-start/basic/src/routes/-prerender-params.server.ts new file mode 100644 index 00000000000..6eb138da4b8 --- /dev/null +++ b/e2e/vue-start/basic/src/routes/-prerender-params.server.ts @@ -0,0 +1,11 @@ +import '@tanstack/vue-start/server-only' + +export const serverOnlyPrerenderMarker = + 'server-only-prerender-marker-should-not-be-in-client' + +export function getServerOnlyPrerenderSlug() { + return serverOnlyPrerenderMarker.replace( + 'server-only-prerender-marker-should-not-be-in-client', + 'server-only-slug', + ) +} diff --git a/e2e/vue-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx b/e2e/vue-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx new file mode 100644 index 00000000000..0f698bd76eb --- /dev/null +++ b/e2e/vue-start/basic/src/routes/_layout/_layout-2/prerender-nested.$slug.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute( + '/_layout/_layout-2/prerender-nested/$slug', +)({ + prerenderParams: () => [{ params: { slug: 'under-layout' } }], + component: RouteComponent, +}) + +function RouteComponent() { + const params = Route.useParams() + + return
Nested prerendered slug: {params.value.slug}
+} diff --git a/e2e/vue-start/basic/src/routes/prerender-params.$slug.tsx b/e2e/vue-start/basic/src/routes/prerender-params.$slug.tsx new file mode 100644 index 00000000000..43f56e23a50 --- /dev/null +++ b/e2e/vue-start/basic/src/routes/prerender-params.$slug.tsx @@ -0,0 +1,60 @@ +import { createFileRoute } from '@tanstack/vue-router' +import z from 'zod' +import { getServerOnlyPrerenderSlug } from './-prerender-params.server' + +export const Route = createFileRoute('/prerender-params/$slug')({ + validateSearch: z.object({ + page: z.number().optional(), + tag: z.string().optional(), + }), + sitemap: { + changefreq: 'weekly', + }, + prerenderParams: () => [ + { + params: { slug: 'hello-world' }, + sitemap: { + lastmod: '2026-05-05', + priority: 0.8, + }, + }, + { + params: { slug: '대한민국' }, + sitemap: { + priority: 0.6, + }, + }, + { + params: { slug: 'reserved?hash#plus+' }, + sitemap: { + exclude: true, + }, + }, + { + params: { slug: 'with-query' }, + search: { page: 2, tag: 'router start' }, + sitemap: { + priority: 0.4, + }, + }, + { + params: { slug: getServerOnlyPrerenderSlug() }, + sitemap: { + exclude: true, + }, + }, + ], + component: RouteComponent, +}) + +function RouteComponent() { + const params = Route.useParams() + const search = Route.useSearch() + + return ( +
+ Prerendered slug: {params.value.slug}. Search page:{' '} + {search.value.page ?? 'none'}. Search tag: {search.value.tag ?? 'none'} +
+ ) +} diff --git a/e2e/vue-start/basic/tests/prerendering.spec.ts b/e2e/vue-start/basic/tests/prerendering.spec.ts index ddcf720a4c0..c256c0d6007 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,125 @@ 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).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', () => { + const htmlPath = join( + distDir, + 'prerender-params/server-only-slug/index.html', + ) + + expect(existsSync(htmlPath)).toBe(true) + expect( + readdirSync(distDir, { recursive: true }).some((relativePath) => { + const filePath = join(distDir, String(relativePath)) + return ( + statSync(filePath).isFile() && + readFileSync(filePath, 'utf-8').includes( + 'server-only-prerender-marker', + ) + ) + }), + ).toBe(false) + }) + + test('should include route sitemap options from prerenderParams', () => { + const sitemapPath = join(distDir, 'sitemap.xml') + + test.skip(!existsSync(sitemapPath), 'Skipping since sitemap is disabled') + expect(existsSync(sitemapPath)).toBe(true) + + const sitemap = readFileSync(sitemapPath, 'utf-8') + expect(sitemap).toContain( + 'https://example.com/prerender-params/hello-world', + ) + expect(sitemap).toContain('2026-05-05') + expect(sitemap).toContain('0.8') + expect(sitemap).toContain('weekly') + expect(sitemap).toContain( + 'https://example.com/prerender-params/대한민국', + ) + expect(sitemap).toContain('0.6') + expect(sitemap).toContain( + 'https://example.com/prerender-params/with-query?page=2&tag=router+start', + ) + expect(sitemap).toContain('0.4') + expect(sitemap).not.toContain( + 'https://example.com/prerender-params/server-only-slug', + ) + }) }) }) diff --git a/e2e/vue-start/basic/vite.config.ts b/e2e/vue-start/basic/vite.config.ts index 6fe8d5d1948..746ab47996b 100644 --- a/e2e/vue-start/basic/vite.config.ts +++ b/e2e/vue-start/basic/vite.config.ts @@ -48,6 +48,12 @@ export default defineConfig({ tanstackStart({ spa: isSpaMode ? spaModeConfiguration : undefined, prerender: isPrerender ? prerenderConfiguration : undefined, + sitemap: isPrerender + ? { + enabled: true, + host: 'https://example.com', + } + : undefined, }), vueJsx(), ], diff --git a/packages/start-client-core/src/index.tsx b/packages/start-client-core/src/index.tsx index f7835c98592..2162a3e8152 100644 --- a/packages/start-client-core/src/index.tsx +++ b/packages/start-client-core/src/index.tsx @@ -105,6 +105,7 @@ export type { } from './constants' export type * from './serverRoute' +export type * from './prerenderParams' export type * from './startEntry' diff --git a/packages/start-client-core/src/prerenderParams.ts b/packages/start-client-core/src/prerenderParams.ts new file mode 100644 index 00000000000..bbb888c2562 --- /dev/null +++ b/packages/start-client-core/src/prerenderParams.ts @@ -0,0 +1,108 @@ +import type { + AnyContext, + AnyRoute, + Awaitable, + Expand, + ResolveAllParamsFromParent, + ResolveFullSearchSchemaInput, +} from '@tanstack/router-core' + +declare module '@tanstack/router-core' { + /* eslint-disable unused-imports/no-unused-vars */ + interface FilebaseRouteOptionsInterface< + TRegister, + TParentRoute extends AnyRoute = AnyRoute, + TId extends string = string, + TPath extends string = string, + TSearchValidator = undefined, + TParams = {}, + TLoaderDeps extends Record = {}, + TLoaderFn = undefined, + TRouterContext = {}, + TRouteContextFn = AnyContext, + TBeforeLoadFn = AnyContext, + TRemountDepsFn = AnyContext, + TSSR = unknown, + TServerMiddlewares = unknown, + THandlers = undefined, + > { + prerenderParams?: ( + ctx: PrerenderParamsContext, + ) => Awaitable< + ReadonlyArray< + PrerenderParamsEntry< + Expand>, + Expand> + > + > + > + sitemap?: RouteSitemapOptions + } + /* eslint-enable unused-imports/no-unused-vars */ +} + +export interface PrerenderParamsContext { + routePath: TPath + signal: AbortSignal +} + +type PrerenderParamsSearch = unknown extends TSearch + ? { search?: Record } + : {} extends TSearch + ? { search?: Expand } + : { search: Expand } + +export type PrerenderParamsEntry = { + params: TParams + sitemap?: RouteSitemapOptions + prerender?: RoutePrerenderOptions +} & PrerenderParamsSearch + +export interface RouteSitemapOptions { + exclude?: boolean + priority?: number + changefreq?: + | 'always' + | 'hourly' + | 'daily' + | 'weekly' + | 'monthly' + | 'yearly' + | 'never' + lastmod?: string | Date + alternateRefs?: Array<{ + href: string + hreflang: string + }> + images?: Array<{ + loc: string + caption?: string + title?: string + }> + news?: { + publication: { + name: string + language: string + } + publicationDate: string | Date + title: string + } +} + +export interface RoutePrerenderOptions { + enabled?: boolean + outputPath?: string + autoSubfolderIndex?: boolean + crawlLinks?: boolean + retryCount?: number + retryDelay?: number + onSuccess?: (opts: { + page: { + path: string + sitemap?: RouteSitemapOptions + fromCrawl?: boolean + } + html: string + }) => any + headers?: Record +} diff --git a/packages/start-client-core/src/tests/prerenderParams.test-d.ts b/packages/start-client-core/src/tests/prerenderParams.test-d.ts new file mode 100644 index 00000000000..dbee7860d82 --- /dev/null +++ b/packages/start-client-core/src/tests/prerenderParams.test-d.ts @@ -0,0 +1,227 @@ +import { expectTypeOf, test } from 'vitest' +import type {} from '../prerenderParams' +import type { AnyRoute, FileBaseRouteOptions } from '@tanstack/router-core' + +type ParentRoute = Omit & { + types: Omit & { + allParams: { + orgId: string + } + } +} + +test('prerenderParams uses route path and all params', () => { + const options = { + prerenderParams: (ctx) => { + expectTypeOf(ctx.routePath).toEqualTypeOf<'/posts/$slug'>() + expectTypeOf(ctx.signal).toEqualTypeOf() + + return [ + { + params: { + orgId: 'tanstack', + slug: 'hello-world', + }, + }, + ] + }, + sitemap: { + priority: 0.7, + changefreq: 'weekly', + }, + } satisfies FileBaseRouteOptions< + unknown, + ParentRoute, + string, + '/posts/$slug', + undefined, + { slug: string } + > + + expectTypeOf(options.sitemap.changefreq).toEqualTypeOf<'weekly'>() + + type Entry = Awaited< + ReturnType> + >[number] + + expectTypeOf().toEqualTypeOf<{ + orgId: string + slug: string + }>() +}) + +test('prerenderParams requires parent and route params', () => { + const options = { + // @ts-expect-error orgId is inherited from the parent route and required + prerenderParams: () => [ + { + params: { + slug: 'hello-world', + }, + }, + ], + } satisfies FileBaseRouteOptions< + unknown, + ParentRoute, + string, + '/posts/$slug', + undefined, + { slug: string } + > + + expectTypeOf(options).toEqualTypeOf() +}) + +test('prerenderParams supports multiple params, optional params, and splats', () => { + const multipleParams = { + prerenderParams: () => [ + { + params: { + category: 'guides', + slug: 'routing', + }, + }, + ], + } satisfies FileBaseRouteOptions< + unknown, + AnyRoute, + string, + '/posts/$category/$slug', + undefined, + { category: string; slug: string } + > + + type MultipleParamsEntry = Awaited< + ReturnType> + >[number] + + expectTypeOf().toEqualTypeOf<{ + category: string + slug: string + }>() + expectTypeOf(multipleParams).toEqualTypeOf() + + const optionalParams = { + prerenderParams: () => [ + { + params: {}, + }, + { + params: { + category: 'guides', + }, + }, + ], + } satisfies FileBaseRouteOptions< + unknown, + AnyRoute, + string, + '/posts/{-$category}/{-$slug}', + undefined, + { category?: string; slug?: string } + > + + type OptionalParamsEntry = Awaited< + ReturnType> + >[number] + + expectTypeOf().toMatchTypeOf<{ + category?: string + slug?: string + }>() + expectTypeOf(optionalParams).toEqualTypeOf() + + const splatParams = { + prerenderParams: () => [ + { + params: { + _splat: 'docs/routing', + }, + }, + ], + } satisfies FileBaseRouteOptions< + unknown, + AnyRoute, + string, + '/files/$', + undefined, + { _splat: string } + > + + type SplatParamsEntry = Awaited< + ReturnType> + >[number] + + expectTypeOf().toEqualTypeOf<{ + _splat: string + }>() + expectTypeOf(splatParams).toEqualTypeOf() +}) + +test('prerenderParams infers and requires search params', () => { + type ParentSearchRoute = Omit & { + types: Omit & { + fullSearchSchemaInput: { + locale?: string + } + } + } + + type SearchValidator = (input: { page: number; tag?: string }) => { + page: number + tag?: string + } + + const options = { + prerenderParams: () => [ + { + params: { + slug: 'hello-world', + }, + search: { + locale: 'en', + page: 2, + tag: 'router', + }, + }, + ], + } satisfies FileBaseRouteOptions< + unknown, + ParentSearchRoute, + string, + '/posts/$slug', + SearchValidator, + { slug: string } + > + + type Entry = Awaited< + ReturnType> + >[number] + + expectTypeOf().toMatchTypeOf<{ + locale?: string + page: number + tag?: string + }>() + expectTypeOf(options).toEqualTypeOf() + + const missingSearch = { + // @ts-expect-error page is required by the route search schema + prerenderParams: () => [ + { + params: { + slug: 'hello-world', + }, + }, + ], + } satisfies FileBaseRouteOptions< + unknown, + ParentSearchRoute, + string, + '/posts/$slug', + SearchValidator, + { slug: string } + > + + expectTypeOf(missingSearch).toEqualTypeOf() +}) diff --git a/packages/start-plugin-core/src/build-sitemap.ts b/packages/start-plugin-core/src/build-sitemap.ts index 3eae17ca021..200aca496d8 100644 --- a/packages/start-plugin-core/src/build-sitemap.ts +++ b/packages/start-plugin-core/src/build-sitemap.ts @@ -204,6 +204,8 @@ function createXml(elementName: 'urlset' | 'sitemapindex'): XMLBuilder { .ele(elementName, { xmlns: 'https://www.sitemaps.org/schemas/sitemap/0.9', 'xmlns:xhtml': 'http://www.w3.org/1999/xhtml', + 'xmlns:image': 'http://www.google.com/schemas/sitemap-image/1.1', + 'xmlns:news': 'http://www.google.com/schemas/sitemap-news/0.9', }) .com(`This file was automatically generated by TanStack Start.`) } diff --git a/packages/start-plugin-core/src/constants.ts b/packages/start-plugin-core/src/constants.ts index f142bb2881a..c2edd8660e6 100644 --- a/packages/start-plugin-core/src/constants.ts +++ b/packages/start-plugin-core/src/constants.ts @@ -2,6 +2,7 @@ export const START_ENVIRONMENT_NAMES = { // 'ssr' is chosen as the name for the server environment to ensure backwards compatibility // with vite plugins that are not compatible with the new vite environment API (e.g. tailwindcss) server: 'ssr', + prerender: 'prerender', client: 'client', } as const diff --git a/packages/start-plugin-core/src/global.d.ts b/packages/start-plugin-core/src/global.d.ts index c974e5019e8..91a859c0188 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,8 @@ declare global { } > var TSS_PRERENDABLE_PATHS: Array<{ path: string }> | undefined + var TSS_PRERENDER_ROUTE_TREE: + | (() => Promise) + | undefined } export {} diff --git a/packages/start-plugin-core/src/post-build.ts b/packages/start-plugin-core/src/post-build.ts index 966cdd38cca..a9c6898b359 100644 --- a/packages/start-plugin-core/src/post-build.ts +++ b/packages/start-plugin-core/src/post-build.ts @@ -19,15 +19,26 @@ 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), } } + const spaOnly = Boolean( + startConfig.spa?.enabled && startConfig.prerender?.enabled !== true, + ) + if (startConfig.spa?.enabled) { + if (spaOnly) { + startConfig.pages = [] + } + startConfig.prerender = { ...startConfig.prerender, + ...(spaOnly + ? { + autoStaticPathsDiscovery: false, + } + : {}), enabled: true, } @@ -51,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 new file mode 100644 index 00000000000..c8ac212eb4a --- /dev/null +++ b/packages/start-plugin-core/src/prerender-params-runner.ts @@ -0,0 +1,242 @@ +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 +} + +interface PrerenderParamsEntry { + params: Record + search?: Record + sitemap?: RouteSitemapOptions + prerender?: RoutePrerenderOptions +} + +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 || isDynamicPath(route.path)) continue + + pagesByPath.set(route.path, merge(page, { sitemap: options.sitemap })) + } + + const controller = new AbortController() + const cleanupProcessAbort = attachProcessAbortHandlers(controller) + + try { + for (const route of dynamicRoutes) { + const options = routeOptions.get(route.routePath) + if (!options?.prerenderParams) continue + + if (!isDynamicPath(route.path)) { + logger.warn( + `Skipping prerenderParams for static route ${route.routePath}; static routes are already discovered automatically.`, + ) + continue + } + + const cleanupTimeout = startPrerenderParamsTimeout( + controller, + prerenderParamsTimeout, + route.routePath, + ) + + 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`, + ) + } + + for (const entry of entries) { + const page = createPageFromParams(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 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', + ) + } + + 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, + }) + + 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' + ) +} + +function merge(base: Page, override: Partial): Page { + return { + ...base, + ...override, + sitemap: mergeOptions(base.sitemap, override.sitemap), + prerender: mergeOptions(base.prerender, override.prerender), + } +} + +function mergeOptions( + base: T | undefined, + override: T | undefined, +) { + if (!base) return override + if (!override) return base + return { ...base, ...override } +} + +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 new file mode 100644 index 00000000000..51ae3a8ed13 --- /dev/null +++ b/packages/start-plugin-core/src/prerender-route-options-env.ts @@ -0,0 +1,41 @@ +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 shouldSeparateRouteOptions( + startConfig: TanStackStartOutputConfig, +) { + if (startConfig.prerender?.separateRouteOptionsBundle === false) { + return false + } + + const prerenderEnabled = + startConfig.prerender?.enabled ?? + startConfig.pages.some((page) => page.prerender?.enabled) + + return prerenderEnabled || Boolean(startConfig.spa?.enabled) +} diff --git a/packages/start-plugin-core/src/prerender-route-options.ts b/packages/start-plugin-core/src/prerender-route-options.ts new file mode 100644 index 00000000000..456a11fdcc1 --- /dev/null +++ b/packages/start-plugin-core/src/prerender-route-options.ts @@ -0,0 +1,83 @@ +import type { AnyRoute } from '@tanstack/router-core' +import type { + PrerenderParamsEntry, + RoutePrerenderOptions, + RouteSitemapOptions, +} from '@tanstack/start-client-core' + +export interface PrerenderRouteMetadata { + path: string + routePath: string +} + +export interface PrerenderRouteOptions { + prerenderParams?: (ctx: { + routePath: string + signal: AbortSignal + }) => + | ReadonlyArray>> + | Promise>>> + prerender?: RoutePrerenderOptions + 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, + prerender: options.prerender, + sitemap: options.sitemap, + }) + } + } + + const children = route.children + if (!children) { + return + } + + for (const child of Array.isArray(children) + ? children + : Object.values(children)) { + visit(child as AnyRoute) + } + } +} diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts index 2fbd9e74b9c..a73648b369e 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 @@ -24,34 +25,42 @@ 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) { + if (!globalThis.TSS_PRERENDER_ROUTE_TREE) { + throw new Error('Prerender route options were not loaded') + } - startConfig.pages = pages - } + const routeTree = await globalThis.TSS_PRERENDER_ROUTE_TREE() - const routerBasePath = joinURL('/', startConfig.router.basepath ?? '') - const routerBaseUrl = new URL(routerBasePath, 'http://localhost') + pages = await runPrerenderParams({ + routeTree, + pages, + logger, + filter: startConfig.prerender.filter, + prerenderParamsTimeout: startConfig.prerender.prerenderParamsTimeout, + }) + } - startConfig.pages = validateAndNormalizePrerenderPages( - startConfig.pages, - routerBaseUrl, - ) + startConfig.pages = pages + } - try { const pages = await prerenderPages({ outputDir: handler.getClientOutputDirectory(), }) @@ -64,6 +73,7 @@ export async function prerender({ logger.error(error) throw error } finally { + delete globalThis.TSS_PRERENDER_ROUTE_TREE await handler.close?.() } @@ -282,7 +292,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/planning.ts b/packages/start-plugin-core/src/rsbuild/planning.ts index 5ab3b631f2e..cb05aad5d97 100644 --- a/packages/start-plugin-core/src/rsbuild/planning.ts +++ b/packages/start-plugin-core/src/rsbuild/planning.ts @@ -11,6 +11,7 @@ const require = createRequire(import.meta.url) export const RSBUILD_ENVIRONMENT_NAMES = { client: 'client', server: 'ssr', + prerender: 'prerender', } as const /** @@ -71,6 +72,7 @@ export function createRsbuildEnvironmentPlan(opts: { serverOutputDirectory: string publicBase: string serverFnProviderEnv: string + separatePrerenderRouteOptions: boolean environmentOverrides?: RsbuildEnvironmentOverrides rsc?: boolean | undefined dev?: boolean | undefined @@ -161,6 +163,42 @@ export function createRsbuildEnvironmentPlan(opts: { environmentOverrides.all, environmentOverrides.server, ), + ...(opts.separatePrerenderRouteOptions + ? { + [RSBUILD_ENVIRONMENT_NAMES.prerender]: mergeRsbuildConfig( + { + source: { + entry: { + index: { + import: opts.entryAliases.server, + html: false, + ...(opts.rsc ? { layer: RSBUILD_RSC_LAYERS.ssr } : {}), + }, + }, + }, + output: { + target: 'node', + module: true, + distPath: { + root: `${opts.serverOutputDirectory}/.tanstack/prerender`, + }, + }, + resolve: { + alias, + }, + ...(opts.rsc + ? { + splitChunks: { + preset: 'single-vendor', + }, + } + : {}), + }, + environmentOverrides.all, + environmentOverrides.prerender, + ), + } + : {}), // When provider is a separate environment (not layered RSC), // create a third environment. With the layered RSC setup this branch // is not taken because provider maps to the same `ssr` environment. diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts index 337cdd55c42..91abf93aadf 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 { shouldSeparateRouteOptions } 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', @@ -146,6 +148,7 @@ export function tanStackStartRsbuild( const entryAliases = createRsbuildResolvedEntryAliases({ entryPaths: resolvedEntryPlan.entryPaths, }) + const separateRouteOptions = shouldSeparateRouteOptions(startConfig) const environmentPlan = createRsbuildEnvironmentPlan({ root, @@ -154,10 +157,25 @@ export function tanStackStartRsbuild( serverOutputDirectory: resolvedStartConfig.outputDirectories.server, publicBase: resolvedStartConfig.basePaths.publicBase, serverFnProviderEnv, + separatePrerenderRouteOptions: separateRouteOptions, environmentOverrides: corePluginOpts.rsbuild?.environments, rsc: rscOpts, dev: isDev, }) + 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, @@ -236,6 +254,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. @@ -256,6 +279,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 @@ -673,11 +697,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: separateRouteOptions, }) }) } diff --git a/packages/start-plugin-core/src/rsbuild/post-build.ts b/packages/start-plugin-core/src/rsbuild/post-build.ts index 2590ec9931a..6316e75a505 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, @@ -19,26 +28,36 @@ export async function postBuildWithRsbuild({ getClientOutputDirectory() { return clientOutputDirectory }, - prerender(startConfig) { + async prerender(startConfig) { + const handler = await createRsbuildPrerenderHandler({ + clientOutputDirectory, + serverOutputDirectory, + prerenderOutputDirectory, + separatePrerenderRouteOptions, + }) + return prerender({ startConfig, - handler: createRsbuildPrerenderHandler({ - clientOutputDirectory, - serverOutputDirectory, - }), + handler, }) }, }, }) } -function createRsbuildPrerenderHandler({ +async function createRsbuildPrerenderHandler({ clientOutputDirectory, serverOutputDirectory, + prerenderOutputDirectory, + separatePrerenderRouteOptions, }: { clientOutputDirectory: string serverOutputDirectory: string -}): PrerenderHandler { + prerenderOutputDirectory?: string | undefined + separatePrerenderRouteOptions: boolean +}): Promise { + const prerenderEnvState = capturePrerenderEnv() + process.env.TSS_PRERENDERING = 'true' process.env.TSS_CLIENT_OUTPUT_DIR = clientOutputDirectory @@ -48,12 +67,14 @@ function createRsbuildPrerenderHandler({ > | undefined - return { + let routeOptionsPromise: Promise | undefined + + const handler: PrerenderHandler = { getClientOutputDirectory() { return clientOutputDirectory }, async request(path, options) { - const requestHandler = await getRequestHandler() + const requestHandler = await loadRequestHandler() const url = new URL(path, 'http://localhost') return requestHandler( @@ -63,18 +84,68 @@ function createRsbuildPrerenderHandler({ }), ) }, + async close() { + delete globalThis.TSS_PRERENDER_ROUTE_TREE + restorePrerenderEnv(prerenderEnvState) + if (separatePrerenderRouteOptions) { + await fsp.rm(getPrerenderOutputDirectory(), { + recursive: true, + force: true, + }) + } + }, } - function getRequestHandler() { + try { + await loadRouteOptions() + await loadRequestHandler() + } catch (error) { + await handler.close?.() + throw error + } + + return handler + + function loadRequestHandler() { if (!requestHandlerPromise) { - requestHandlerPromise = loadRequestHandler(serverOutputDirectory) + requestHandlerPromise = loadRequestHandlerFromBundle( + serverOutputDirectory, + ) } 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 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-compiler-host.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts index 066d5b28676..7b11b5f4d7d 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,11 +77,9 @@ 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.prerender, type: 'server' }, { name: RSBUILD_ENVIRONMENT_NAMES.server, type: 'server' }, ] @@ -92,7 +94,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 1f512089feb..2e81a3b0102 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,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, + SERVER_ROUTE_OPTION_DELETE_NODES, +} from '../start-router-plugin/constants' +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' @@ -52,7 +57,7 @@ export function registerRouterPlugins( }, plugins: [ routesManifestPlugin(), - ...(opts.startPluginOpts?.prerender?.enabled === true + ...(opts.startPluginOpts?.prerender?.enabled !== false ? [prerenderRoutesPlugin()] : []), ], @@ -64,16 +69,23 @@ 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 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 ? ['ssr', 'server', 'headers'] : undefined, + deleteNodes, addHmr: isClient, }, }, diff --git a/packages/start-plugin-core/src/rsbuild/types.ts b/packages/start-plugin-core/src/rsbuild/types.ts index fa10d65f640..4be276b5b04 100644 --- a/packages/start-plugin-core/src/rsbuild/types.ts +++ b/packages/start-plugin-core/src/rsbuild/types.ts @@ -5,6 +5,7 @@ export interface RsbuildEnvironmentOverrides { all?: EnvironmentConfig | undefined client?: EnvironmentConfig | undefined server?: EnvironmentConfig | undefined + prerender?: EnvironmentConfig | undefined provider?: EnvironmentConfig | undefined } diff --git a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts index 2f7d92a4d47..5abee50bc2c 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,8 @@ 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 +406,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 +420,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 +550,9 @@ export function createFromReadableStream() { throw new Error('RSC SSR decode is updateServerFnResolver() { for (const environmentName of new Set([ RSBUILD_ENVIRONMENT_NAMES.server, + ...(vmPlugins[RSBUILD_ENVIRONMENT_NAMES.prerender] + ? [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 e0287858e63..1840069bbf5 100644 --- a/packages/start-plugin-core/src/schema.ts +++ b/packages/start-plugin-core/src/schema.ts @@ -252,7 +252,9 @@ 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(), }) .and(pagePrerenderOptionsSchema.optional()) .optional(), diff --git a/packages/start-plugin-core/src/start-router-plugin/constants.ts b/packages/start-plugin-core/src/start-router-plugin/constants.ts index 645c550aac4..253f977c7d4 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,10 @@ export const SERVER_PROP = 'server' +export const CLIENT_ROUTE_OPTION_DELETE_NODES = [ + 'ssr', + 'server', + 'headers', + 'prerenderParams', + 'sitemap', +] + +export const SERVER_ROUTE_OPTION_DELETE_NODES = ['prerenderParams', 'sitemap'] diff --git a/packages/start-plugin-core/src/vite/planning.ts b/packages/start-plugin-core/src/vite/planning.ts index 5db724ba8df..11c9eb419e1 100644 --- a/packages/start-plugin-core/src/vite/planning.ts +++ b/packages/start-plugin-core/src/vite/planning.ts @@ -44,9 +44,15 @@ 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 + return { environments: { [START_ENVIRONMENT_NAMES.client]: { @@ -75,19 +81,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 +98,34 @@ export function createViteConfigPlan(opts: { ]), }, }, + ...(opts.separatePrerenderRouteOptions + ? { + [START_ENVIRONMENT_NAMES.prerender]: { + consumer: 'server', + build: { + ssr: true, + ...buildViteInputOptions( + typeof serverInput === 'string' + ? { server: serverInput } + : serverInput, + [/^cloudflare:/], + ), + 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,18 @@ function escapeEntries(entries: Array) { return entries.map((entry) => escapePath(entry)) } +function buildViteInputOptions( + input: NonNullable['input'], + external?: NonNullable['external'], +) { + const bundlerOptions = external ? { input, external } : { input } + + return { + rollupOptions: bundlerOptions, + rolldownOptions: bundlerOptions, + } +} + function defineReplaceEnv( key: TKey, value: TValue, diff --git a/packages/start-plugin-core/src/vite/plugin.ts b/packages/start-plugin-core/src/vite/plugin.ts index 915fcd7acae..a8b3b62c15b 100644 --- a/packages/start-plugin-core/src/vite/plugin.ts +++ b/packages/start-plugin-core/src/vite/plugin.ts @@ -10,6 +10,7 @@ import { normalizePublicBase, shouldRewriteDevBasepath, } from '../planning' +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' @@ -61,19 +62,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 +107,7 @@ export function tanStackStartVite( serverOutputDirectory: getServerOutputDirectory(viteConfig), }) const { startConfig } = getConfig() + const separateRouteOptions = shouldSeparateRouteOptions(startConfig) const routerBasepath = applyResolvedRouterBasepath({ resolvedStartConfig, startConfig, @@ -165,6 +174,7 @@ export function tanStackStartVite( clientOutputDirectory: resolvedStartConfig.outputDirectories.client, serverOutputDirectory: resolvedStartConfig.outputDirectories.server, serverFnProviderEnv, + separatePrerenderRouteOptions: separateRouteOptions, optimizeDepsExclude: crawlFrameworkPkgsResult.optimizeDeps.exclude, noExternal: crawlFrameworkPkgsResult.ssr.noExternal.sort(), }) @@ -196,6 +206,7 @@ export function tanStackStartVite( builder, providerEnvironmentName: serverFnProviderEnv, ssrIsProvider, + separatePrerenderRouteOptions: separateRouteOptions, }) }, }, diff --git a/packages/start-plugin-core/src/vite/prerender.ts b/packages/start-plugin-core/src/vite/prerender.ts index 5eeb76e8d50..f039057e12e 100644 --- a/packages/start-plugin-core/src/vite/prerender.ts +++ b/packages/start-plugin-core/src/vite/prerender.ts @@ -1,7 +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 type { PrerenderHandler } from '../prerender' +import { + capturePrerenderEnv, + restorePrerenderEnv, + shouldSeparateRouteOptions, +} 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({ @@ -27,12 +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 previewServer = await startPreviewServer(serverEnv.config) - const baseUrl = getResolvedUrl(previewServer) + let routeOptionsOutputDir: string | undefined + let previewServer: PreviewServer | undefined + let baseUrl: URL + + try { + routeOptionsOutputDir = await importRouteOptionsEntry({ + startConfig, + serverEnv, + prerenderEnv: builder.environments[VITE_ENVIRONMENT_NAMES.prerender], + }) + + 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() { @@ -42,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 }) + } }, } @@ -53,6 +90,158 @@ export async function prerenderWithVite({ }) } +async function importRouteOptionsEntry({ + startConfig, + serverEnv, + prerenderEnv, +}: { + startConfig: TanStackStartOutputConfig + serverEnv: NonNullable + prerenderEnv: ViteBuilder['environments'][string] | undefined +}): Promise { + const separateRouteOptions = shouldSeparateRouteOptions(startConfig) + const routeOptionsEnv = separateRouteOptions ? prerenderEnv : serverEnv + + if (!routeOptionsEnv) { + throw new Error( + `Vite's "${VITE_ENVIRONMENT_NAMES.prerender}" environment not found`, + ) + } + + const outputName = getRouteOptionsEntryName( + getBundlerOptions(routeOptionsEnv.config.build)?.input ?? 'server', + ) + + if (!outputName) { + 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, 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: Array = [] + + for (const entry of entries) { + const entryPath = join(directory, entry.name) + + if (entry.isDirectory()) { + const match = await findEntryFile(entryPath, outputName) + if (match) { + matches.push(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.push(entryPath) + } + } + + if (matches.length === 1) { + return matches[0] + } + + if (matches.length > 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 getRouteOptionsEntryName(input: unknown): string | undefined { + if (typeof input === 'string') { + return 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 entries[0]![0] + } + + const serverEntry = entries.find(([name]) => name === 'server') + + if (serverEntry) { + return serverEntry[0] + } + + throw new Error('Unable to resolve Vite route-options entry point') + } + + return undefined +} + async function startPreviewServer( viteConfig: ResolvedConfig, ): Promise { diff --git a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts index 3130ca47c15..3e844fad892 100644 --- a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts @@ -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,13 @@ 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 e4f06893a7c..dd19d725768 100644 --- a/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts @@ -10,7 +10,12 @@ 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, + SERVER_ROUTE_OPTION_DELETE_NODES, +} from '../../start-router-plugin/constants' +import { shouldSeparateRouteOptions } from '../../prerender-route-options-env' import type { GetConfigFn } from '../../types' import type { TanStackStartVitePluginCoreOptions } from '../types' import type { @@ -147,7 +152,7 @@ export function tanStackStartRouter( tanstackRouterGenerator(() => { const routerConfig = getConfig().startConfig.router const plugins = [clientTreeGeneratorPlugin, routesManifestPlugin()] - if (startPluginOpts.prerender?.enabled === true) { + if (startPluginOpts.prerender?.enabled !== false) { plugins.push(prerenderRoutesPlugin()) } return { @@ -163,7 +168,7 @@ export function tanStackStartRouter( ...routerConfig, codeSplittingOptions: { ...routerConfig.codeSplittingOptions, - deleteNodes: ['ssr', 'server', 'headers'], + deleteNodes: CLIENT_ROUTE_OPTION_DELETE_NODES, addHmr: true, }, plugin: { @@ -172,11 +177,15 @@ export function tanStackStartRouter( } }, routerPluginContext), tanStackRouterCodeSplitter(() => { - const routerConfig = getConfig().startConfig.router + const { startConfig } = getConfig() + const routerConfig = startConfig.router return { ...routerConfig, codeSplittingOptions: { ...routerConfig.codeSplittingOptions, + deleteNodes: shouldSeparateRouteOptions(startConfig) + ? SERVER_ROUTE_OPTION_DELETE_NODES + : undefined, addHmr: false, }, plugin: { @@ -184,5 +193,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/build-sitemap.test.ts b/packages/start-plugin-core/tests/build-sitemap.test.ts new file mode 100644 index 00000000000..afc105ac70f --- /dev/null +++ b/packages/start-plugin-core/tests/build-sitemap.test.ts @@ -0,0 +1,213 @@ +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' +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('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('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) + + 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/post-server-build.test.ts b/packages/start-plugin-core/tests/post-server-build.test.ts index 763f5e5d05d..b6479b611ee 100644 --- a/packages/start-plugin-core/tests/post-server-build.test.ts +++ b/packages/start-plugin-core/tests/post-server-build.test.ts @@ -11,6 +11,27 @@ vi.mock('../src/build-sitemap', () => ({ })) describe('postServerBuild', () => { + 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') + + await postBuild({ + startConfig: { + pages: [], + router: { basepath: '' }, + serverFns: { base: '' }, + spa: { enabled: false }, + sitemap: { enabled: false }, + } as any, + adapter: { + getClientOutputDirectory: () => '/client', + prerender, + }, + }) + + 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') @@ -40,4 +61,84 @@ describe('postServerBuild', () => { expect(prerender).not.toHaveBeenCalled() }) + + it('keeps explicit prerender pages in SPA mode', 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: '/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: [ + expect.objectContaining({ + path: '/', + }), + ], + prerender: expect.objectContaining({ + enabled: true, + autoStaticPathsDiscovery: 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 new file mode 100644 index 00000000000..2b0a4175ac3 --- /dev/null +++ b/packages/start-plugin-core/tests/prerender-params-runner.test.ts @@ -0,0 +1,557 @@ +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 }, + prerender: { retryDelay: 100, retryCount: 2 }, + 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: { retryDelay: 100, 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(/operation was aborted/i) + + 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 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': { + 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-route-options-env.test.ts b/packages/start-plugin-core/tests/prerender-route-options-env.test.ts new file mode 100644 index 00000000000..939efd47dc7 --- /dev/null +++ b/packages/start-plugin-core/tests/prerender-route-options-env.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { shouldSeparateRouteOptions } 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(shouldSeparateRouteOptions(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(shouldSeparateRouteOptions(startConfig)).toBe(false) + }) + + it('is enabled for SPA builds so final server output is stripped', () => { + const startConfig = parseStartConfig( + { + spa: { enabled: true }, + prerender: { enabled: true }, + }, + { framework: 'react' }, + process.cwd(), + ) + + expect(shouldSeparateRouteOptions(startConfig)).toBe(true) + }) +}) diff --git a/packages/start-plugin-core/tests/prerender-routes-plugin.test.ts b/packages/start-plugin-core/tests/prerender-routes-plugin.test.ts new file mode 100644 index 00000000000..7d365b8fc94 --- /dev/null +++ b/packages/start-plugin-core/tests/prerender-routes-plugin.test.ts @@ -0,0 +1,65 @@ +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 + }) + + it('stores static 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' }) + }) + + 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: '/' }]) + }) +}) diff --git a/packages/start-plugin-core/tests/prerender-ssrf.test.ts b/packages/start-plugin-core/tests/prerender-ssrf.test.ts index e245a132493..18c8885dc52 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') @@ -39,6 +39,7 @@ const handler = { function resetFetch() { fetchMock.mockClear() + globalThis.TSS_PRERENDER_ROUTE_TREE = async () => undefined } function makeStartConfig(pagePath: string) { @@ -73,6 +74,36 @@ 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') + 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') @@ -88,4 +119,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/rsbuild-post-build.test.ts b/packages/start-plugin-core/tests/rsbuild-post-build.test.ts index 52311daa76e..406099fcda4 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,14 @@ vi.mock('@tanstack/start-server-core', () => ({ })) 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 + }) + 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 +57,7 @@ describe('postBuildWithRsbuild', () => { } as any, clientOutputDirectory: '/client', serverOutputDirectory, + separatePrerenderRouteOptions: false, }) expect(prerenderSpy).toHaveBeenCalledOnce() @@ -56,4 +65,118 @@ 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 new file mode 100644 index 00000000000..5a8f73fb07c --- /dev/null +++ b/packages/start-plugin-core/tests/start-router-plugin-constants.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +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', () => { + expect(CLIENT_ROUTE_OPTION_DELETE_NODES).toEqual([ + 'ssr', + 'server', + 'headers', + 'prerenderParams', + '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-planning.test.ts b/packages/start-plugin-core/tests/vite-planning.test.ts new file mode 100644 index 00000000000..d134b9958d1 --- /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 the server entry input for the prerender route options environment', () => { + const entryAliases = createViteResolvedEntryAliases({ + entryPaths: { + client: '/app/src/client.tsx', + server: '/app/src/server.tsx', + start: '/app/src/start.tsx', + router: '/app/src/router.tsx', + }, + }) + + const plan = createViteConfigPlan({ + viteConfig: {}, + framework: 'react', + entryAliases, + clientOutputDirectory: '/app/dist/client', + serverOutputDirectory: '/app/dist/server', + serverFnProviderEnv: 'ssr', + separatePrerenderRouteOptions: true, + optimizeDepsExclude: [], + noExternal: [], + }) + + const prerenderEnvironment = plan.environments.prerender + + expect(prerenderEnvironment).toBeDefined() + expect(prerenderEnvironment!.build.rollupOptions.input).toEqual({ + server: '/app/src/server.tsx', + }) + }) +}) diff --git a/packages/start-plugin-core/tests/vite-prerender.test.ts b/packages/start-plugin-core/tests/vite-prerender.test.ts new file mode 100644 index 00000000000..e97a321f6e7 --- /dev/null +++ b/packages/start-plugin-core/tests/vite-prerender.test.ts @@ -0,0 +1,281 @@ +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 }) + } + }) + + 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) { + 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 a31a81fde82..e904a37492f 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -370,6 +370,16 @@ function getEntries() { return entriesPromise } +if (process.env.TSS_PRERENDERING === 'true') { + // The prerenderer imports the server entry before crawling so it can read + // server-only route options like prerenderParams from the initialized router. + globalThis.TSS_PRERENDER_ROUTE_TREE ??= async () => { + const entries = await getEntries() + const router = await entries.routerEntry.getRouter() + return router.routeTree + } +} + /** * Returns the raw manifest data (without client entry script tag baked in). * In dev mode, always returns fresh data. In prod, cached. diff --git a/packages/start-server-core/src/global.d.ts b/packages/start-server-core/src/global.d.ts index dd9048293ef..e357c26767e 100644 --- a/packages/start-server-core/src/global.d.ts +++ b/packages/start-server-core/src/global.d.ts @@ -1,4 +1,12 @@ +import type { AnyRoute } from '@tanstack/router-core' + +/* eslint-disable no-var */ declare global { + /** Set by the built server entry while prerendering dynamic route params. */ + var TSS_PRERENDER_ROUTE_TREE: + | (() => Promise) + | undefined + namespace NodeJS { interface ProcessEnv { TSS_ROUTER_BASEPATH: string