diff --git a/docs/start/framework/react/guide/static-prerendering.md b/docs/start/framework/react/guide/static-prerendering.md index 3f1c0048fde..e358fafcd96 100644 --- a/docs/start/framework/react/guide/static-prerendering.md +++ b/docs/start/framework/react/guide/static-prerendering.md @@ -25,6 +25,9 @@ export default defineConfig({ // Enable if you need pages to be at `/page/index.html` instead of `/page.html` autoSubfolderIndex: true, + // If disabled, only the root path or the paths defined in the pages config will be prerendered + autoStaticPathsDiscovery: true, + // How many prerender jobs to run at once concurrency: 14, @@ -40,13 +43,20 @@ export default defineConfig({ // Delay between retries in milliseconds retryDelay: 1000, + // Maximum number of redirects to follow during prerendering + maxRedirects: 5, + + // Fail if an error occurs during prerendering + failOnError: true, + // Callback when page is successfully rendered onSuccess: ({ page }) => { console.log(`Rendered ${page.path}!`) }, }, - // Optional configuration for specific pages (without this it will still automatically - // prerender all routes) + // Optional configuration for specific pages + // Note: When autoStaticPathsDiscovery is enabled (default), discovered static + // routes will be merged with the pages specified below pages: [ { path: '/my-page', @@ -58,3 +68,21 @@ export default defineConfig({ ], }) ``` + +## Automatic Static Route Discovery + +All static paths will be automatically discovered and seamlessly merged with the specified `pages` config + +Routes are excluded from automatic discovery in the following cases: + +- Routes with path parameters (e.g., `/users/$userId`) since they require specific parameter values +- Layout routes (prefixed with `_`) since they don't render standalone pages +- Routes without components (e.g., API routes) + +Note: Dynamic routes can still be prerendered if they are linked from other pages when `crawlLinks` is enabled. + +## Crawling Links + +When `crawlLinks` is enabled (default: `true`), TanStack Start will extract links from prerendered pages and prerender those linked pages as well. + +For example, if `/` contains a link to `/posts`, then `/posts` will also be automatically prerendered. diff --git a/docs/start/framework/solid/guide/static-prerendering.md b/docs/start/framework/solid/guide/static-prerendering.md index 7c79b6995d9..5a98af5e6c5 100644 --- a/docs/start/framework/solid/guide/static-prerendering.md +++ b/docs/start/framework/solid/guide/static-prerendering.md @@ -25,6 +25,9 @@ export default defineConfig({ // Enable if you need pages to be at `/page/index.html` instead of `/page.html` autoSubfolderIndex: true, + // If disabled, only the root path or the paths defined in the pages config will be prerendered + autoStaticPathsDiscovery: true, + // How many prerender jobs to run at once concurrency: 14, @@ -40,13 +43,20 @@ export default defineConfig({ // Delay between retries in milliseconds retryDelay: 1000, + // Maximum number of redirects to follow during prerendering + maxRedirects: 5, + + // Fail if an error occurs during prerendering + failOnError: true, + // Callback when page is successfully rendered onSuccess: ({ page }) => { console.log(`Rendered ${page.path}!`) }, }, - // Optional configuration for specific pages (without this it will still automatically - // prerender all routes) + // Optional configuration for specific pages + // Note: When autoStaticPathsDiscovery is enabled (default), discovered static + // routes will be merged with the pages specified below pages: [ { path: '/my-page', @@ -58,3 +68,21 @@ export default defineConfig({ ], }) ``` + +## Automatic Static Route Discovery + +All static paths will be automatically discovered and seamlessly merged with the specified `pages` config + +Routes are excluded from automatic discovery in the following cases: + +- Routes with path parameters (e.g., `/users/$userId`) since they require specific parameter values +- Layout routes (prefixed with `_`) since they don't render standalone pages +- Routes without components (e.g., API routes) + +Note: Dynamic routes can still be prerendered if they are linked from other pages when `crawlLinks` is enabled. + +## Crawling Links + +When `crawlLinks` is enabled (default: `true`), TanStack Start will extract links from prerendered pages and prerender those linked pages as well. + +For example, if `/` contains a link to `/posts`, then `/posts` will also be automatically prerendered. diff --git a/e2e/react-start/basic/package.json b/e2e/react-start/basic/package.json index 73497ac94da..f8887ccfac3 100644 --- a/e2e/react-start/basic/package.json +++ b/e2e/react-start/basic/package.json @@ -8,11 +8,15 @@ "dev:e2e": "vite dev", "build": "vite build && tsc --noEmit", "build:spa": "MODE=spa vite build && tsc --noEmit", + "build:prerender": "MODE=prerender vite build && tsc --noEmit", "start": "pnpx srvx --prod -s ../client dist/server/server.js", "start:spa": "node server.js", + "test:e2e:startDummyServer": "node -e 'import(\"./tests/setup/global.setup.ts\").then(m => m.default())' &", + "test:e2e:stopDummyServer": "node -e 'import(\"./tests/setup/global.teardown.ts\").then(m => m.default())'", "test:e2e:spaMode": "rm -rf port*.txt; MODE=spa playwright test --project=chromium", "test:e2e:ssrMode": "rm -rf port*.txt; playwright test --project=chromium", - "test:e2e": "pnpm run test:e2e:spaMode && pnpm run test:e2e:ssrMode" + "test:e2e:prerender": "rm -rf port*.txt; MODE=prerender playwright test --project=chromium", + "test:e2e": "pnpm run test:e2e:spaMode && pnpm run test:e2e:ssrMode && pnpm run test:e2e:prerender" }, "dependencies": { "@tanstack/react-router": "workspace:^", diff --git a/e2e/react-start/basic/playwright.config.ts b/e2e/react-start/basic/playwright.config.ts index ef3f68c5edf..493f4e29edc 100644 --- a/e2e/react-start/basic/playwright.config.ts +++ b/e2e/react-start/basic/playwright.config.ts @@ -4,6 +4,7 @@ import { getTestServerPort, } from '@tanstack/router-e2e-utils' import { isSpaMode } from './tests/utils/isSpaMode' +import { isPrerender } from './tests/utils/isPrerender' import packageJson from './package.json' with { type: 'json' } const PORT = await getTestServerPort( @@ -16,8 +17,15 @@ const EXTERNAL_PORT = await getDummyServerPort(packageJson.name) const baseURL = `http://localhost:${PORT}` const spaModeCommand = `pnpm build:spa && pnpm start:spa` const ssrModeCommand = `pnpm build && pnpm start` +const prerenderModeCommand = `pnpm run test:e2e:startDummyServer && pnpm build:prerender && pnpm run test:e2e:stopDummyServer && pnpm start` +const getCommand = () => { + if (isSpaMode) return spaModeCommand + if (isPrerender) return prerenderModeCommand + return ssrModeCommand +} console.log('running in spa mode: ', isSpaMode.toString()) +console.log('running in prerender mode: ', isPrerender.toString()) /** * See https://playwright.dev/docs/test-configuration. */ @@ -35,7 +43,7 @@ export default defineConfig({ }, webServer: { - command: isSpaMode ? spaModeCommand : ssrModeCommand, + command: getCommand(), url: baseURL, reuseExistingServer: !process.env.CI, stdout: 'pipe', diff --git a/e2e/react-start/basic/tests/prerendering.spec.ts b/e2e/react-start/basic/tests/prerendering.spec.ts new file mode 100644 index 00000000000..9a3ec145dc4 --- /dev/null +++ b/e2e/react-start/basic/tests/prerendering.spec.ts @@ -0,0 +1,53 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { isPrerender } from './utils/isPrerender' + +test.describe('Prerender Static Path Discovery', () => { + test.skip(!isPrerender, 'Skipping since not in prerender mode') + test.describe('Build Output Verification', () => { + test('should automatically discover and prerender static routes', () => { + // Check that static routes were automatically discovered and prerendered + const distDir = join(process.cwd(), 'dist', 'client') + + // These static routes should be automatically discovered and prerendered + expect(existsSync(join(distDir, 'index.html'))).toBe(true) + expect(existsSync(join(distDir, 'posts/index.html'))).toBe(true) + expect(existsSync(join(distDir, 'users/index.html'))).toBe(true) + expect(existsSync(join(distDir, 'deferred/index.html'))).toBe(true) + expect(existsSync(join(distDir, 'scripts/index.html'))).toBe(true) + expect(existsSync(join(distDir, 'inline-scripts/index.html'))).toBe(true) + expect(existsSync(join(distDir, '대한민국/index.html'))).toBe(true) + + // Pathless layouts should NOT be prerendered (they start with _) + expect(existsSync(join(distDir, '_layout', 'index.html'))).toBe(false) // /_layout + + // API routes should NOT be prerendered + + expect(existsSync(join(distDir, 'api', 'users', 'index.html'))).toBe( + false, + ) // /api/users + }) + }) + + test.describe('Static Files Verification', () => { + test('should contain prerendered content in posts.html', () => { + const distDir = join(process.cwd(), 'dist', 'client') + expect(existsSync(join(distDir, 'posts/index.html'))).toBe(true) + + // "Select a post." should be in the prerendered HTML + const html = readFileSync(join(distDir, 'posts/index.html'), 'utf-8') + expect(html).toContain('Select a post.') + }) + + test('should contain prerendered content in users.html', () => { + const distDir = join(process.cwd(), 'dist', 'client') + expect(existsSync(join(distDir, 'users/index.html'))).toBe(true) + + // "Select a user." should be in the prerendered HTML + const html = readFileSync(join(distDir, 'users/index.html'), 'utf-8') + expect(html).toContain('Select a user.') + }) + }) +}) diff --git a/e2e/react-start/basic/tests/search-params.spec.ts b/e2e/react-start/basic/tests/search-params.spec.ts index e3fe274c200..00e3f871a84 100644 --- a/e2e/react-start/basic/tests/search-params.spec.ts +++ b/e2e/react-start/basic/tests/search-params.spec.ts @@ -1,6 +1,7 @@ import { expect } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' import { isSpaMode } from 'tests/utils/isSpaMode' +import { isPrerender } from './utils/isPrerender' import type { Response } from '@playwright/test' function expectRedirect(response: Response | null, endsWith: string) { @@ -27,7 +28,7 @@ test.describe('/search-params/loader-throws-redirect', () => { }) => { const response = await page.goto('/search-params/loader-throws-redirect') - if (!isSpaMode) { + if (!isSpaMode && !isPrerender) { expectRedirect(response, '/search-params/loader-throws-redirect?step=a') } @@ -52,7 +53,7 @@ test.describe('/search-params/default', () => { page, }) => { const response = await page.goto('/search-params/default') - if (!isSpaMode) { + if (!isSpaMode && !isPrerender) { expectRedirect(response, '/search-params/default?default=d1') } await expect(page.getByTestId('search-default')).toContainText('d1') @@ -65,7 +66,7 @@ test.describe('/search-params/default', () => { test('Directly visiting the route with search param set', async ({ page, }) => { - const response = await page.goto('/search-params/default/?default=d2') + const response = await page.goto('/search-params/default?default=d2') expectNoRedirect(response) await expect(page.getByTestId('search-default')).toContainText('d2') diff --git a/e2e/react-start/basic/tests/utils/isPrerender.ts b/e2e/react-start/basic/tests/utils/isPrerender.ts new file mode 100644 index 00000000000..d5d991d4545 --- /dev/null +++ b/e2e/react-start/basic/tests/utils/isPrerender.ts @@ -0,0 +1 @@ +export const isPrerender: boolean = process.env.MODE === 'prerender' diff --git a/e2e/react-start/basic/vite.config.ts b/e2e/react-start/basic/vite.config.ts index b91cd686950..c34468b880f 100644 --- a/e2e/react-start/basic/vite.config.ts +++ b/e2e/react-start/basic/vite.config.ts @@ -3,6 +3,7 @@ import tsConfigPaths from 'vite-tsconfig-paths' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import viteReact from '@vitejs/plugin-react' import { isSpaMode } from './tests/utils/isSpaMode' +import { isPrerender } from './tests/utils/isPrerender' const spaModeConfiguration = { enabled: true, @@ -11,6 +12,19 @@ const spaModeConfiguration = { }, } +const prerenderConfiguration = { + enabled: true, + filter: (page: { path: string }) => + ![ + '/this-route-does-not-exist', + '/redirect', + '/i-do-not-exist', + '/not-found/via-beforeLoad', + '/not-found/via-loader', + ].some((p) => page.path.includes(p)), + maxRedirects: 100, +} + export default defineConfig({ server: { port: 3000, @@ -22,6 +36,7 @@ export default defineConfig({ // @ts-ignore we want to keep one test with verboseFileRoutes off even though the option is hidden tanstackStart({ spa: isSpaMode ? spaModeConfiguration : undefined, + prerender: isPrerender ? prerenderConfiguration : undefined, }), viteReact(), ], diff --git a/packages/router-generator/src/index.ts b/packages/router-generator/src/index.ts index 835d8f5301d..de3df56d777 100644 --- a/packages/router-generator/src/index.ts +++ b/packages/router-generator/src/index.ts @@ -27,6 +27,7 @@ export { format, removeExt, checkRouteFullPathUniqueness, + inferFullPath, } from './utils' export type { diff --git a/packages/start-plugin-core/src/global.d.ts b/packages/start-plugin-core/src/global.d.ts index f395eaca355..5f647b0c1aa 100644 --- a/packages/start-plugin-core/src/global.d.ts +++ b/packages/start-plugin-core/src/global.d.ts @@ -3,5 +3,6 @@ import type { Manifest } from '@tanstack/router-core' /* eslint-disable no-var */ declare global { var TSS_ROUTES_MANIFEST: Manifest + var TSS_PRERENDABLE_PATHS: Array<{ path: string }> | undefined } export {} diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts index 79a20e60458..4683cb7b821 100644 --- a/packages/start-plugin-core/src/prerender.ts +++ b/packages/start-plugin-core/src/prerender.ts @@ -21,13 +21,26 @@ export async function prerender({ const logger = createLogger('prerender') logger.info('Prerendering pages...') - // If prerender is enabled but no pages are provided, default to prerendering the root page - if (startConfig.prerender?.enabled && !startConfig.pages.length) { - startConfig.pages = [ - { - path: '/', - }, - ] + // If prerender is enabled + if (startConfig.prerender?.enabled) { + // default to root page if no pages are defined + let pages = startConfig.pages.length ? startConfig.pages : [{ path: '/' }] + + if (startConfig.prerender.autoStaticPathsDiscovery ?? true) { + // merge discovered static pages with user-defined pages + 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) + } + } + + pages = Array.from(pagesMap.values()) + } + + startConfig.pages = pages } const serverEnv = builder.environments[VITE_ENVIRONMENT_NAMES.server] @@ -69,9 +82,28 @@ export async function prerender({ pathToFileURL(fullEntryFilePath).toString() ) - function localFetch(path: string, options?: RequestInit): Promise { + const isRedirectResponse = (res: Response) => { + return res.status >= 300 && res.status < 400 && res.headers.get('location') + } + async function localFetch( + path: string, + options?: RequestInit, + maxRedirects: number = 5, + ): Promise { const url = new URL(`http://localhost${path}`) - return serverEntrypoint.fetch(new Request(url, options)) + const response = await serverEntrypoint.fetch(new Request(url, options)) + + if (isRedirectResponse(response) && maxRedirects > 0) { + const location = response.headers.get('location')! + if (location.startsWith('http://localhost') || location.startsWith('/')) { + const newUrl = location.replace('http://localhost', '') + return localFetch(newUrl, options, maxRedirects - 1) + } else { + logger.warn(`Skipping redirect to external location: ${location}`) + } + } + + return response } try { @@ -103,6 +135,7 @@ export async function prerender({ async function prerenderPages({ outputDir }: { outputDir: string }) { const seen = new Set() + const prerendered = new Set() const retriesByPath = new Map() const concurrency = startConfig.prerender?.concurrency ?? os.cpus().length logger.info(`Concurrency: ${concurrency}`) @@ -113,7 +146,7 @@ export async function prerender({ await queue.start() - return Array.from(seen) + return Array.from(prerendered) function addCrawlPageTask(page: Page) { // Was the page already seen? @@ -147,13 +180,20 @@ export async function prerender({ // Fetch the route const encodedRoute = encodeURI(page.path) - const res = await localFetch(withBase(encodedRoute, routerBasePath), { - headers: { - ...prerenderOptions.headers, + const res = await localFetch( + withBase(encodedRoute, routerBasePath), + { + headers: { + ...(prerenderOptions.headers ?? {}), + }, }, - }) + prerenderOptions.maxRedirects, + ) if (!res.ok) { + if (isRedirectResponse(res)) { + logger.warn(`Max redirects reached for ${page.path}`) + } throw new Error(`Failed to fetch ${page.path}: ${res.statusText}`, { cause: res, }) @@ -194,6 +234,8 @@ export async function prerender({ await fsp.writeFile(filepath, html) + prerendered.add(page.path) + const newPage = await prerenderOptions.onSuccess?.({ page, html }) if (newPage) { diff --git a/packages/start-plugin-core/src/schema.ts b/packages/start-plugin-core/src/schema.ts index 39d471447da..7dec32effda 100644 --- a/packages/start-plugin-core/src/schema.ts +++ b/packages/start-plugin-core/src/schema.ts @@ -186,6 +186,8 @@ const tanstackStartOptionsSchema = z concurrency: z.number().optional(), filter: z.function().args(pageSchema).returns(z.any()).optional(), failOnError: z.boolean().optional(), + autoStaticPathsDiscovery: z.boolean().optional(), + maxRedirects: z.number().min(0).optional(), }) .and(pagePrerenderOptionsSchema.optional()) .optional(), diff --git a/packages/start-plugin-core/src/start-router-plugin/generator-plugins/prerender-routes-plugin.ts b/packages/start-plugin-core/src/start-router-plugin/generator-plugins/prerender-routes-plugin.ts new file mode 100644 index 00000000000..b614ae4afae --- /dev/null +++ b/packages/start-plugin-core/src/start-router-plugin/generator-plugins/prerender-routes-plugin.ts @@ -0,0 +1,38 @@ +import { inferFullPath } from '@tanstack/router-generator' +import type { GeneratorPlugin, RouteNode } from '@tanstack/router-generator' + +/** + * this plugin gets the prerenderable paths and stores it on globalThis + * so that it can be accessed later (e.g. from a vite plugin) + */ +export function prerenderRoutesPlugin(): GeneratorPlugin { + return { + name: 'prerender-routes-plugin', + onRouteTreeChanged: ({ routeNodes }) => { + globalThis.TSS_PRERENDABLE_PATHS = getPrerenderablePaths(routeNodes) + }, + } +} + +function getPrerenderablePaths( + routeNodes: Array, +): Array<{ path: string }> { + const paths = new Set(['/']) + + for (const route of routeNodes) { + if (!route.routePath) continue + // filter routes that are layout + if (route.isNonPath === true) continue + + // filter dynamic routes + // if routePath contains $ it is dynamic + if (route.routePath.includes('$')) continue + + // filter routes that do not have a component, i.e api routes + if (!route.createFileRouteProps?.has('component')) continue + + paths.add(inferFullPath(route)) + } + + return Array.from(paths).map((path) => ({ path })) +} diff --git a/packages/start-plugin-core/src/start-router-plugin/plugin.ts b/packages/start-plugin-core/src/start-router-plugin/plugin.ts index 0e292fee29e..4543eac384b 100644 --- a/packages/start-plugin-core/src/start-router-plugin/plugin.ts +++ b/packages/start-plugin-core/src/start-router-plugin/plugin.ts @@ -7,6 +7,7 @@ import { normalizePath } from 'vite' import path from 'pathe' import { VITE_ENVIRONMENT_NAMES } from '../constants' import { routesManifestPlugin } from './generator-plugins/routes-manifest-plugin' +import { prerenderRoutesPlugin } from './generator-plugins/prerender-routes-plugin' import { pruneServerOnlySubtrees } from './pruneServerOnlySubtrees' import { SERVER_PROP } from './constants' import type { @@ -209,11 +210,15 @@ export function tanStackStartRouter( clientTreePlugin, tanstackRouterGenerator(() => { const routerConfig = getConfig().startConfig.router + const plugins = [clientTreeGeneratorPlugin, routesManifestPlugin()] + if (startPluginOpts?.prerender?.enabled === true) { + plugins.push(prerenderRoutesPlugin()) + } return { ...routerConfig, target: corePluginOpts.framework, routeTreeFileFooter: getRouteTreeFileFooter, - plugins: [clientTreeGeneratorPlugin, routesManifestPlugin()], + plugins, } }), tanStackRouterCodeSplitter(() => {