From c63f6f221025bb43f4c79778d97cd3b819c65fae Mon Sep 17 00:00:00 2001 From: Nitesh Seram Date: Wed, 4 Nov 2026 17:39:09 +0530 Subject: [PATCH] Fix static page-data requests for non-prerendered routes --- packages/react/src/runtime/pages-client.ts | 2 +- packages/void/src/pages/manifest.ts | 11 ++- packages/void/src/pages/navigation-engine.ts | 9 ++- packages/void/test/unit/pages-client.test.ts | 79 +++++++++++++++++++ .../void/test/unit/pages-manifest.test.ts | 27 ++++++- 5 files changed, 122 insertions(+), 6 deletions(-) diff --git a/packages/react/src/runtime/pages-client.ts b/packages/react/src/runtime/pages-client.ts index dcb85a69c..4472a2f64 100644 --- a/packages/react/src/runtime/pages-client.ts +++ b/packages/react/src/runtime/pages-client.ts @@ -49,7 +49,7 @@ type ReactLayoutComponent = ComponentType<{ children?: React.ReactNode }>; export type ReactPagesConfig = Readonly<{ components: Record Promise>; layoutTree: Record>; - routeMeta: Record; + routeMeta: Record; /** Page id for a decoded pathname. */ matchRoute(path: string): string | null; staticPageData: boolean; diff --git a/packages/void/src/pages/manifest.ts b/packages/void/src/pages/manifest.ts index b0757b123..ac4301043 100644 --- a/packages/void/src/pages/manifest.ts +++ b/packages/void/src/pages/manifest.ts @@ -81,11 +81,18 @@ for (const [pattern, id, load, chain] of __voidDynamicRoutes) { ); } - // Route metadata (sparse — only island pages) + // Sparse route metadata: only flags that differ from the defaults. lines.push('const routeMeta = {'); for (const page of scan.pages) { + const flags = []; if (page.island) { - lines.push(` ${JSON.stringify(page.componentId)}: { island: true },`); + flags.push('island: true'); + } + if (page.prerender === false) { + flags.push('prerender: false'); + } + if (flags.length > 0) { + lines.push(` ${JSON.stringify(page.componentId)}: { ${flags.join(', ')} },`); } } lines.push('};'); diff --git a/packages/void/src/pages/navigation-engine.ts b/packages/void/src/pages/navigation-engine.ts index 598d2b0fc..595852e75 100644 --- a/packages/void/src/pages/navigation-engine.ts +++ b/packages/void/src/pages/navigation-engine.ts @@ -81,7 +81,7 @@ export type PageNavigationEngineConfig = { adapter: VoidRouterAdapter; components: Record Promise>; layoutTree: Record>; - routeMeta: Record; + routeMeta: Record; /** Page id for a decoded pathname (see `decodePagePath`). */ matchRoute(path: string): string | null; staticPageData?: boolean; @@ -474,7 +474,12 @@ export async function createPageNavigationEngine( /** Route id for a non-island page that static page data can serve, or null. */ function matchStaticPageId(fetchUrl: string): string | null { const matchedId = matchRoute(fetchUrl || window.location.pathname + window.location.search); - return matchedId && !routeMeta[matchedId]?.island && components[matchedId] ? matchedId : null; + return matchedId && + !routeMeta[matchedId]?.island && + routeMeta[matchedId]?.prerender !== false && + components[matchedId] + ? matchedId + : null; } async function loadStaticPageDataFile( diff --git a/packages/void/test/unit/pages-client.test.ts b/packages/void/test/unit/pages-client.test.ts index a48ee7f24..29349eeb9 100644 --- a/packages/void/test/unit/pages-client.test.ts +++ b/packages/void/test/unit/pages-client.test.ts @@ -9,8 +9,10 @@ import { submitAction, type VoidRouterConfig, } from '../../src/pages/client.ts'; +import { generateComponentManifest } from '../../src/pages/manifest.ts'; import { createPageNavigationEngine } from '../../src/pages/navigation-engine.ts'; import { parseCacheFor } from '../../src/pages/prefetch.ts'; +import type { PageDefinition } from '../../src/pages/types.ts'; import { createPageMutationHandler, createRoutingRuntime, @@ -126,6 +128,83 @@ afterEach(() => { }); describe('createVoidRouter', () => { + describe.each(['navigation', 'prefetch'] as const)('static page data during %s', (operation) => { + it.each([true, false, undefined])('respects prerender = %s', async (prerender) => { + stubClientGlobals(); + const page: PageDefinition = { + componentId: 'blog/[slug]', + componentPath: 'blog/[slug].tsx', + pattern: '/blog/:slug', + params: ['slug'], + catchAll: false, + serverPath: 'blog/[slug].server.ts', + methods: ['loader'], + hasGetPrerenderPaths: true, + prerender, + prerenderMode: prerender === false ? 'disabled' : prerender === true ? 'force' : 'auto', + shouldPrerender: prerender !== false, + ssrMode: 'auto', + renderMode: 'server', + }; + const source = generateComponentManifest( + { pages: [page], layouts: [], namedLayouts: [] }, + '/project/pages', + ); + const manifest = new Function(`${source}\nreturn { routeMeta, matchRoute };`)() as Pick< + VoidRouterConfig, + 'routeMeta' | 'matchRoute' + >; + const url = '/blog/example'; + const pageData = { + component: page.componentId, + props: { title: 'Example' }, + url, + errors: null, + }; + const fetchMock = vi.fn(async (input: string) => { + if (input === '/_void/pages/blog/example.json') { + return prerender === false + ? new Response(null, { status: 404 }) + : Response.json(pageData); + } + return pageJson(pageData); + }); + vi.stubGlobal('fetch', fetchMock); + const applyUpdate = vi.fn(); + const { router } = await createTestRouter({ + ...manifest, + components: { home: loadComponent, [page.componentId]: loadComponent }, + staticPageData: true, + staticPageDataFastPath: true, + adapter: { applyUpdate }, + }); + try { + if (operation === 'navigation') { + await router.visit(url); + expect(applyUpdate.mock.calls[0][0]).toMatchObject(pageData); + } else { + await expect(router.prefetch(url)).resolves.toEqual(pageData); + } + expect(fetchMock.mock.calls.map(([input]) => input)).toEqual([ + prerender === false ? url : '/_void/pages/blog/example.json', + ]); + if (prerender === false) { + expect(fetchMock).toHaveBeenCalledWith( + url, + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-VoidPages': 'true', + ...(operation === 'prefetch' ? { Purpose: 'prefetch' } : {}), + }), + }), + ); + } + } finally { + router.flushAll(); + } + }); + }); + it.each(['POST', 'PUT', 'PATCH', 'DELETE'])( 'preserves scroll for an in-place %s action', async (method) => { diff --git a/packages/void/test/unit/pages-manifest.test.ts b/packages/void/test/unit/pages-manifest.test.ts index 878203777..8b58cca05 100644 --- a/packages/void/test/unit/pages-manifest.test.ts +++ b/packages/void/test/unit/pages-manifest.test.ts @@ -49,7 +49,7 @@ describe('generated Pages client route manifest', () => { type GeneratedManifest = { components: Record Promise>; layoutTree: Record>; - routeMeta: Record; + routeMeta: Record; matchRoute(url: string): string | null; }; @@ -83,6 +83,31 @@ function definePage( }; } +it('preserves explicit prerender opt-outs alongside island metadata', () => { + const manifest = evaluateManifest({ + pages: [ + definePage('static', '/static', 'static.tsx', { prerender: true, shouldPrerender: true }), + definePage('auto', '/auto', 'auto.tsx'), + definePage('server', '/server', 'server.tsx', { + prerender: false, + prerenderMode: 'disabled', + }), + definePage('island', '/island', 'island.island.tsx', { + island: true, + prerender: false, + prerenderMode: 'disabled', + }), + ], + layouts: [], + namedLayouts: [], + }); + + expect(manifest.routeMeta).toEqual({ + server: { prerender: false }, + island: { island: true, prerender: false }, + }); +}); + /** * The ordered RegExp scan alone: a dynamic route placed first leaves no static * prefix, so every page is tested by its RegExp in route order.