From ec4a73e8afc5f3c70805968ce443bfce5ad9443d Mon Sep 17 00:00:00 2001 From: Birk Skyum Date: Sun, 26 Oct 2025 02:30:27 +0200 Subject: [PATCH 1/4] prerendering - sync #5476 to solid-start --- e2e/solid-start/basic/package.json | 6 ++- e2e/solid-start/basic/playwright.config.ts | 17 ++++-- .../basic/tests/prerendering.spec.ts | 53 +++++++++++++++++++ .../basic/tests/search-params.spec.ts | 9 ++-- .../basic/tests/utils/isPrerender.ts | 1 + e2e/solid-start/basic/vite.config.ts | 15 ++++++ 6 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 e2e/solid-start/basic/tests/prerendering.spec.ts create mode 100644 e2e/solid-start/basic/tests/utils/isPrerender.ts diff --git a/e2e/solid-start/basic/package.json b/e2e/solid-start/basic/package.json index b71b1238dcc..d77a05fe39d 100644 --- a/e2e/solid-start/basic/package.json +++ b/e2e/solid-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/solid-router": "workspace:^", diff --git a/e2e/solid-start/basic/playwright.config.ts b/e2e/solid-start/basic/playwright.config.ts index 786244ff4ce..493f4e29edc 100644 --- a/e2e/solid-start/basic/playwright.config.ts +++ b/e2e/solid-start/basic/playwright.config.ts @@ -3,8 +3,9 @@ import { getDummyServerPort, getTestServerPort, } from '@tanstack/router-e2e-utils' -import packageJson from './package.json' with { type: 'json' } import { isSpaMode } from './tests/utils/isSpaMode' +import { isPrerender } from './tests/utils/isPrerender' +import packageJson from './package.json' with { type: 'json' } const PORT = await getTestServerPort( `${packageJson.name}${isSpaMode ? '_spa' : ''}`, @@ -16,15 +17,21 @@ 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. */ export default defineConfig({ testDir: './tests', workers: 1, - reporter: [['line']], globalSetup: './tests/setup/global.setup.ts', @@ -36,7 +43,7 @@ export default defineConfig({ }, webServer: { - command: isSpaMode ? spaModeCommand : ssrModeCommand, + command: getCommand(), url: baseURL, reuseExistingServer: !process.env.CI, stdout: 'pipe', @@ -53,7 +60,9 @@ export default defineConfig({ projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + use: { + ...devices['Desktop Chrome'], + }, }, ], }) diff --git a/e2e/solid-start/basic/tests/prerendering.spec.ts b/e2e/solid-start/basic/tests/prerendering.spec.ts new file mode 100644 index 00000000000..9a3ec145dc4 --- /dev/null +++ b/e2e/solid-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/solid-start/basic/tests/search-params.spec.ts b/e2e/solid-start/basic/tests/search-params.spec.ts index 64ef39919ed..74c1aa54dc9 100644 --- a/e2e/solid-start/basic/tests/search-params.spec.ts +++ b/e2e/solid-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 { isSpaMode } from 'tests/utils/isSpaMode' +import { isPrerender } from './utils/isPrerender' import type { Response } from '@playwright/test' function expectRedirect(response: Response | null, endsWith: string) { @@ -26,9 +27,11 @@ test.describe('/search-params/loader-throws-redirect', () => { page, }) => { const response = await page.goto('/search-params/loader-throws-redirect') - if (!isSpaMode) { + + if (!isSpaMode && !isPrerender) { expectRedirect(response, '/search-params/loader-throws-redirect?step=a') } + await expect(page.getByTestId('search-param')).toContainText('a') expect(page.url().endsWith('/search-params/loader-throws-redirect?step=a')) }) @@ -50,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') diff --git a/e2e/solid-start/basic/tests/utils/isPrerender.ts b/e2e/solid-start/basic/tests/utils/isPrerender.ts new file mode 100644 index 00000000000..d5d991d4545 --- /dev/null +++ b/e2e/solid-start/basic/tests/utils/isPrerender.ts @@ -0,0 +1 @@ +export const isPrerender: boolean = process.env.MODE === 'prerender' diff --git a/e2e/solid-start/basic/vite.config.ts b/e2e/solid-start/basic/vite.config.ts index 60c1947e40c..f4fe143b6f6 100644 --- a/e2e/solid-start/basic/vite.config.ts +++ b/e2e/solid-start/basic/vite.config.ts @@ -3,6 +3,7 @@ import tsConfigPaths from 'vite-tsconfig-paths' import { tanstackStart } from '@tanstack/solid-start/plugin/vite' import viteSolid from 'vite-plugin-solid' 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, }), viteSolid({ ssr: true }), ], From cc55fd47906b950be205033970b8153fec76e6f7 Mon Sep 17 00:00:00 2001 From: Birk Skyum Date: Mon, 27 Oct 2025 02:23:41 +0100 Subject: [PATCH 2/4] prerender fix --- packages/start-plugin-core/src/prerender.ts | 30 +++++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts index 8c22633b374..aba68b7ecd8 100644 --- a/packages/start-plugin-core/src/prerender.ts +++ b/packages/start-plugin-core/src/prerender.ts @@ -89,7 +89,8 @@ export async function prerender({ path: string, options?: RequestInit, maxRedirects: number = 5, - ): Promise { + wasRedirected: boolean = false, + ): Promise<{ response: Response; wasRedirected: boolean; finalPath: string }> { const url = new URL(`http://localhost${path}`) const response = await serverEntrypoint.fetch(new Request(url, options)) @@ -97,13 +98,17 @@ export async function prerender({ 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) + return localFetch(newUrl, options, maxRedirects - 1, true) } else { logger.warn(`Skipping redirect to external location: ${location}`) } } - return response + return { + response, + wasRedirected, + finalPath: path, + } } try { @@ -136,6 +141,7 @@ export async function prerender({ async function prerenderPages({ outputDir }: { outputDir: string }) { const seen = new Set() const prerendered = new Set() + const writtenFiles = new Set() const retriesByPath = new Map() const concurrency = startConfig.prerender?.concurrency ?? os.cpus().length logger.info(`Concurrency: ${concurrency}`) @@ -180,7 +186,7 @@ export async function prerender({ // Fetch the route const encodedRoute = encodeURI(page.path) - const res = await localFetch( + const { response: res, wasRedirected, finalPath } = await localFetch( withBase(encodedRoute, routerBasePath), { headers: { @@ -199,8 +205,14 @@ export async function prerender({ }) } + // Use the final path (after redirects) for determining output location + const pathForOutput = wasRedirected ? finalPath : page.path + if (wasRedirected) { + logger.info(`Page ${page.path} redirected to ${finalPath}, using final path for output`) + } + const cleanPagePath = ( - prerenderOptions.outputPath || page.path + prerenderOptions.outputPath || pathForOutput ).split(/[?#]/)[0]! // Guess route type and populate fileName @@ -239,12 +251,20 @@ export async function prerender({ const filepath = path.join(outputDir, filename) + // Skip writing if this file path has already been written + // This prevents URLs with different query params from overwriting each other + if (writtenFiles.has(filename)) { + logger.info(`Skipping write for ${page.path} - file ${filename} already written`) + return + } + await fsp.mkdir(path.dirname(filepath), { recursive: true, }) await fsp.writeFile(filepath, html) + writtenFiles.add(filename) prerendered.add(page.path) const newPage = await prerenderOptions.onSuccess?.({ page, html }) From 9dbae04f1b8e3e7e7e3746ec11c7562afeba9c6e Mon Sep 17 00:00:00 2001 From: Birk Skyum Date: Mon, 27 Oct 2025 23:50:35 +0100 Subject: [PATCH 3/4] reset prerender --- packages/start-plugin-core/src/prerender.ts | 32 ++++----------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts index aba68b7ecd8..fdf25b942b1 100644 --- a/packages/start-plugin-core/src/prerender.ts +++ b/packages/start-plugin-core/src/prerender.ts @@ -89,8 +89,7 @@ export async function prerender({ path: string, options?: RequestInit, maxRedirects: number = 5, - wasRedirected: boolean = false, - ): Promise<{ response: Response; wasRedirected: boolean; finalPath: string }> { + ): Promise { const url = new URL(`http://localhost${path}`) const response = await serverEntrypoint.fetch(new Request(url, options)) @@ -98,17 +97,13 @@ export async function prerender({ 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, true) + return localFetch(newUrl, options, maxRedirects - 1) } else { logger.warn(`Skipping redirect to external location: ${location}`) } } - return { - response, - wasRedirected, - finalPath: path, - } + return response } try { @@ -141,7 +136,6 @@ export async function prerender({ async function prerenderPages({ outputDir }: { outputDir: string }) { const seen = new Set() const prerendered = new Set() - const writtenFiles = new Set() const retriesByPath = new Map() const concurrency = startConfig.prerender?.concurrency ?? os.cpus().length logger.info(`Concurrency: ${concurrency}`) @@ -186,7 +180,7 @@ export async function prerender({ // Fetch the route const encodedRoute = encodeURI(page.path) - const { response: res, wasRedirected, finalPath } = await localFetch( + const res = await localFetch( withBase(encodedRoute, routerBasePath), { headers: { @@ -205,14 +199,8 @@ export async function prerender({ }) } - // Use the final path (after redirects) for determining output location - const pathForOutput = wasRedirected ? finalPath : page.path - if (wasRedirected) { - logger.info(`Page ${page.path} redirected to ${finalPath}, using final path for output`) - } - const cleanPagePath = ( - prerenderOptions.outputPath || pathForOutput + prerenderOptions.outputPath || page.path ).split(/[?#]/)[0]! // Guess route type and populate fileName @@ -251,20 +239,12 @@ export async function prerender({ const filepath = path.join(outputDir, filename) - // Skip writing if this file path has already been written - // This prevents URLs with different query params from overwriting each other - if (writtenFiles.has(filename)) { - logger.info(`Skipping write for ${page.path} - file ${filename} already written`) - return - } - await fsp.mkdir(path.dirname(filepath), { recursive: true, }) await fsp.writeFile(filepath, html) - writtenFiles.add(filename) prerendered.add(page.path) const newPage = await prerenderOptions.onSuccess?.({ page, html }) @@ -341,4 +321,4 @@ export async function writeBundleToDisk({ await fsp.writeFile(fullPath, content) } -} +} \ No newline at end of file From d7c32dbafea44e5fcc3a869874e7f6ca1d709bea Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Sun, 2 Nov 2025 20:55:35 -0600 Subject: [PATCH 4/4] Fix prerendering --- e2e/solid-start/basic/vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/solid-start/basic/vite.config.ts b/e2e/solid-start/basic/vite.config.ts index f4fe143b6f6..9f4a744b19f 100644 --- a/e2e/solid-start/basic/vite.config.ts +++ b/e2e/solid-start/basic/vite.config.ts @@ -21,6 +21,7 @@ const prerenderConfiguration = { '/i-do-not-exist', '/not-found/via-beforeLoad', '/not-found/via-loader', + '/search-params/default', ].some((p) => page.path.includes(p)), maxRedirects: 100, }