Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/react/src/runtime/pages-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ type ReactLayoutComponent = ComponentType<{ children?: React.ReactNode }>;
export type ReactPagesConfig = Readonly<{
components: Record<string, () => Promise<ComponentModule>>;
layoutTree: Record<string, Array<string>>;
routeMeta: Record<string, { island?: boolean }>;
routeMeta: Record<string, { island?: boolean; prerender?: boolean }>;
/** Page id for a decoded pathname. */
matchRoute(path: string): string | null;
staticPageData: boolean;
Expand Down
11 changes: 9 additions & 2 deletions packages/void/src/pages/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('};');
Expand Down
9 changes: 7 additions & 2 deletions packages/void/src/pages/navigation-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export type PageNavigationEngineConfig = {
adapter: VoidRouterAdapter;
components: Record<string, () => Promise<PageComponentModule>>;
layoutTree: Record<string, Array<string>>;
routeMeta: Record<string, { island?: boolean }>;
routeMeta: Record<string, { island?: boolean; prerender?: boolean }>;
/** Page id for a decoded pathname (see `decodePagePath`). */
matchRoute(path: string): string | null;
staticPageData?: boolean;
Expand Down Expand Up @@ -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(
Expand Down
79 changes: 79 additions & 0 deletions packages/void/test/unit/pages-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down
27 changes: 26 additions & 1 deletion packages/void/test/unit/pages-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ describe('generated Pages client route manifest', () => {
type GeneratedManifest = {
components: Record<string, () => Promise<unknown>>;
layoutTree: Record<string, Array<string>>;
routeMeta: Record<string, { island?: boolean }>;
routeMeta: Record<string, { island?: boolean; prerender?: boolean }>;
matchRoute(url: string): string | null;
};

Expand Down Expand Up @@ -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.
Expand Down