From 8aca01bd8078248068a0a278a976eee6639c5efc Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 10 Jul 2026 15:55:12 +0800 Subject: [PATCH 01/12] =?UTF-8?q?feat(isr):=20collectPages=20=E2=80=94=20o?= =?UTF-8?q?rdered=20page-route=20table=20for=20the=20deploy=20manifest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- packages/void/src/isr-config.ts | 25 +++++++++++++++++++++ packages/void/test/unit/isr-config.test.ts | 26 +++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/void/src/isr-config.ts b/packages/void/src/isr-config.ts index 70e0b24bc..db3bc01d8 100644 --- a/packages/void/src/isr-config.ts +++ b/packages/void/src/isr-config.ts @@ -83,3 +83,28 @@ export function collectRevalidate( } return revalidate; } + +/** + * Build the ordered page-route table shipped in the deploy manifest as + * `pages`. Emits EVERY page route (with or without a TTL) in `pageScan.pages` + * order — which is already specificity-sorted (static → dynamic → catch-all) + * and is the exact order the framework's own `matchRoute` (pages/manifest.ts) + * resolves in. Patterns are emitted RAW (`:slug`, `:path{.+}`, `*`); the edge + * matcher (`matchSegmentPattern`) handles `:name{.+}` natively, so — unlike the + * flat `revalidate` map — catch-alls are NOT collapsed to `*`. + * + * Consumers (dispatch, deploy prewarm) first-match over this list to pick the + * winning route, then apply that route's `revalidate` (0/absent = not ISR). + */ +export function collectPages( + pageScan: PageScanResult | null, +): Array<{ pattern: string; revalidate?: number }> | undefined { + if (!pageScan || pageScan.pages.length === 0) { + return undefined; + } + return pageScan.pages.map((page) => + page.revalidate != null + ? { pattern: page.pattern, revalidate: page.revalidate } + : { pattern: page.pattern }, + ); +} diff --git a/packages/void/test/unit/isr-config.test.ts b/packages/void/test/unit/isr-config.test.ts index df2474f47..358dfc93d 100644 --- a/packages/void/test/unit/isr-config.test.ts +++ b/packages/void/test/unit/isr-config.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vite-plus/test'; import type { VoidConfig } from '../../src/config.ts'; -import { collectRevalidate, hasRevalidate } from '../../src/isr-config.ts'; +import { collectPages, collectRevalidate, hasRevalidate } from '../../src/isr-config.ts'; import type { PageDefinition, PageScanResult } from '../../src/pages/types.ts'; import { resolvePathTtl } from '../../src/runtime/isr-cache.ts'; @@ -148,3 +148,27 @@ describe('collectRevalidate', () => { expect(resolvePathTtl(revalidate, '/about')).toBe(0); }); }); + +describe('collectPages', () => { + it('returns undefined when there is no page scan', () => { + expect(collectPages(null)).toBeUndefined(); + }); + + it('returns undefined when the scan has no pages', () => { + expect(collectPages(makeScan([]))).toBeUndefined(); + }); + + it('emits every page in scan order with raw patterns; revalidate only when set', () => { + // makeScan preserves array order; a real scan is pre-sorted static→dynamic→catch-all. + const scan = makeScan([ + makePage('/docs/setup/install'), // specific, no revalidate (opt-out) + makePage('/blog/:slug', 120), // single-segment dynamic + makePage('/docs/:path{.+}', 3600), // named catch-all — RAW pattern, not `*` + ]); + expect(collectPages(scan)).toEqual([ + { pattern: '/docs/setup/install' }, + { pattern: '/blog/:slug', revalidate: 120 }, + { pattern: '/docs/:path{.+}', revalidate: 3600 }, + ]); + }); +}); From 9170dc72d2a597b5437d689bb1c624b5af18fb37 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 10 Jul 2026 16:13:08 +0800 Subject: [PATCH 02/12] feat(isr): DeployManifest.pages + buildVoidManifest emission Co-Authored-By: Claude Opus 4.8 --- packages/void/src/cli/package.ts | 6 ++++++ packages/void/src/cli/types.ts | 2 ++ packages/void/test/unit/package.test.ts | 14 ++++++++++++++ 3 files changed, 22 insertions(+) diff --git a/packages/void/src/cli/package.ts b/packages/void/src/cli/package.ts index 22b3a0caa..efa091033 100644 --- a/packages/void/src/cli/package.ts +++ b/packages/void/src/cli/package.ts @@ -492,6 +492,7 @@ export function buildVoidManifest(opts: { ssr?: boolean; framework?: string; revalidate?: number | Record; + pages?: Array<{ pattern: string; revalidate?: number }>; vars?: Record; queues?: Array; prerender?: Array; @@ -511,6 +512,7 @@ export function buildVoidManifest(opts: { ssr, framework, revalidate, + pages, vars, queues, prerender, @@ -604,6 +606,10 @@ export function buildVoidManifest(opts: { manifest.revalidateQueryAllowlist = options.revalidateQueryAllowlist; } + if (pages && pages.length > 0) { + manifest.pages = pages; + } + if (prerender && prerender.length > 0) { manifest.prerender = prerender; } diff --git a/packages/void/src/cli/types.ts b/packages/void/src/cli/types.ts index 603f7268c..27a6d4695 100644 --- a/packages/void/src/cli/types.ts +++ b/packages/void/src/cli/types.ts @@ -182,6 +182,8 @@ export type DeployManifest = { type?: 'void' | 'spa' | 'static' | 'framework'; revalidate?: number | Record; revalidateQueryAllowlist?: Record>; + /** Ordered page-route table for route-aware ISR resolution (pages mode). */ + pages?: Array<{ pattern: string; revalidate?: number }>; prerender?: Array; assetConfig?: AssetConfig; vars?: Record; diff --git a/packages/void/test/unit/package.test.ts b/packages/void/test/unit/package.test.ts index 6db1caf1f..b3351c1be 100644 --- a/packages/void/test/unit/package.test.ts +++ b/packages/void/test/unit/package.test.ts @@ -151,6 +151,20 @@ describe('buildVoidManifest — self-host non-regression', () => { expect(revalidate).toBeUndefined(); expect(manifest.revalidate).toBeUndefined(); }); + + it('carries a pages array verbatim into manifest.pages', () => { + const pages = [ + { pattern: '/docs/setup/install' }, + { pattern: '/docs/:path{.+}', revalidate: 3600 }, + ]; + const manifest = buildVoidManifest({ bindings: makeBindings(), pages }); + expect(manifest.pages).toEqual(pages); + }); + + it('omits manifest.pages when the pages array is empty or missing', () => { + expect(buildVoidManifest({ bindings: makeBindings(), pages: [] }).pages).toBeUndefined(); + expect(buildVoidManifest({ bindings: makeBindings() }).pages).toBeUndefined(); + }); }); describe('resolveSpaFallbackChain', () => { From 47e54be5dfa2b7ea0c7190291ad4dfacd954b9f0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 10 Jul 2026 16:25:33 +0800 Subject: [PATCH 03/12] feat(isr): emit manifest.pages from the void deploy path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runFullDeploy now computes `pages = collectPages(pageScan)` and passes it to buildVoidManifest, so the ordered page-route table lands in manifest.pages on the Void deploy path (pages mode only; the 3rd-party framework path is untouched). Adds an integration assertion driving the real scanPages → collectPages → buildVoidManifest chain over the kitchen-sink playground. Co-Authored-By: Claude Opus 4.8 --- packages/void/src/cli/deploy.ts | 4 +- .../integration/kitchen-sink-build.test.ts | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 3e6a72793..afa3e1180 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -33,7 +33,7 @@ import { import { scanJobsSync } from '../crons/scan.ts'; import { writeDrizzleConfig } from '../drizzle/config.ts'; import { discoverSchema } from '../drizzle/discover.ts'; -import { collectRevalidate } from '../isr-config.ts'; +import { collectRevalidate, collectPages } from '../isr-config.ts'; import { voidWarn } from '../log.ts'; import { collectMigrations } from '../migrations/collect.ts'; import { assertJournalCoherence, validateMigrations } from '../migrations/validate.ts'; @@ -1726,6 +1726,7 @@ async function runFullDeploy( : await scanPages(root, { output: deployConfig.output, paths }) : null; const revalidate = collectRevalidate(deployConfig, pageScan); + const pages = collectPages(pageScan); // Fully-static optimization: if output: "static" and all pages are prerendered // with no backend features, deploy as a static site (no worker needed) @@ -1881,6 +1882,7 @@ async function runFullDeploy( ssr: isSsr || hasRoutes || hasMiddleware || hasWebSockets || authEnabled, framework: isFrameworkMode ? detected!.name : undefined, revalidate, + pages, vars: envVars, queues, prerender: uniquePrerenderPaths, diff --git a/packages/void/test/integration/kitchen-sink-build.test.ts b/packages/void/test/integration/kitchen-sink-build.test.ts index 97026d8ee..c6bccf1a6 100644 --- a/packages/void/test/integration/kitchen-sink-build.test.ts +++ b/packages/void/test/integration/kitchen-sink-build.test.ts @@ -2,6 +2,11 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync import spawn from 'cross-spawn'; import { join } from 'pathe'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; +import { buildVoidManifest } from '../../src/cli/package.ts'; +import { readConfig } from '../../src/config.ts'; +import { collectPages } from '../../src/isr-config.ts'; +import { scanPages } from '../../src/pages/scan.ts'; +import { resolveProjectPaths } from '../../src/project-paths.ts'; import { toSlash } from '../../src/shared/utils.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); @@ -122,6 +127,41 @@ describe('playground/kitchen-sink build', () => { expect(vars.LEGIT_USER_VAR).toBe('kept'); }); + it('emits manifest.pages for the Void pages app (scan order, RAW patterns)', async () => { + // The deploy manifest (carrying `pages`) is a `void deploy` artifact, not a + // `vite build` output — the `vp build` above never writes it to `dist`. Drive + // the SAME deploy-side chain `runFullDeploy` uses (scanPages → collectPages → + // buildVoidManifest) against the real kitchen-sink pages to prove the A3 wiring + // emits `manifest.pages` for a Void pages app (which includes an ISR page). + const pg = join(ROOT, PLAYGROUND); + const deployConfig = readConfig(pg); + const paths = resolveProjectPaths(pg, deployConfig); + const pageScan = await scanPages(pg, { output: deployConfig.output, paths }); + const pages = collectPages(pageScan); + const manifest = buildVoidManifest({ + bindings: { + needsD1: false, + needsKV: false, + needsR2: false, + needsAuth: false, + needsQueues: false, + needsAI: false, + needsSandbox: false, + }, + pages, + }); + + // kitchen-sink is a Void pages app → manifest.pages is emitted, in scan order. + expect(Array.isArray(manifest.pages)).toBe(true); + expect(manifest.pages!.length).toBeGreaterThan(0); + // every entry has a string pattern; catch-alls are RAW (`{.+}`/`*`), not normalized away + for (const p of manifest.pages!) { + expect(typeof p.pattern).toBe('string'); + } + // the /revalidate page (export const revalidate = 60) surfaces as an ISR entry + expect(manifest.pages!.some((p) => p.revalidate != null)).toBe(true); + }); + it('includes sandbox bindings and container metadata', () => { const wranglerConfig = JSON.parse(readFileSync(join(DIST, 'ssr', 'wrangler.json'), 'utf-8')); expect(wranglerConfig.compatibility_flags).toContain('nodejs_compat'); From 68440b0569d7fb6ca0d8f1bd28fe6d31783ad345 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 10 Jul 2026 16:37:27 +0800 Subject: [PATCH 04/12] fix(isr): guard manifest.pages wiring via assembleVoidManifest seam Co-Authored-By: Claude Opus 4.8 --- packages/void/src/cli/deploy.ts | 18 ++++- .../integration/kitchen-sink-build.test.ts | 14 ++-- .../void/test/unit/assemble-manifest.test.ts | 65 +++++++++++++++++++ 3 files changed, 86 insertions(+), 11 deletions(-) create mode 100644 packages/void/test/unit/assemble-manifest.test.ts diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index afa3e1180..9a7306661 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -38,6 +38,7 @@ import { voidWarn } from '../log.ts'; import { collectMigrations } from '../migrations/collect.ts'; import { assertJournalCoherence, validateMigrations } from '../migrations/validate.ts'; import { scanPages } from '../pages/scan.ts'; +import type { PageScanResult } from '../pages/types.ts'; import { inferProjectBindings, FRAMEWORK_SCAN_DIRS, @@ -1034,6 +1035,19 @@ function formatKnownAssetSummary(result: DeployDoneEvent, skipped: number): stri : `${result.assets} static asset(s)`; } +/** + * Assemble the Void deploy manifest, deriving the route-aware ISR `pages` table + * from the page scan. This is the single production seam that forwards + * `pageScan` → `manifest.pages`; `runFullDeploy` calls it so the wiring is + * exercised (and regression-guarded) by a unit test. + */ +export function assembleVoidManifest( + pageScan: PageScanResult | null, + opts: Parameters[0], +): DeployManifest { + return buildVoidManifest({ ...opts, pages: collectPages(pageScan) }); +} + export async function runStaticDeploy( root: string, config: ProjectConfig, @@ -1726,7 +1740,6 @@ async function runFullDeploy( : await scanPages(root, { output: deployConfig.output, paths }) : null; const revalidate = collectRevalidate(deployConfig, pageScan); - const pages = collectPages(pageScan); // Fully-static optimization: if output: "static" and all pages are prerendered // with no backend features, deploy as a static site (no worker needed) @@ -1875,14 +1888,13 @@ async function runFullDeploy( console.warn(`[void] ${fallbackWarning}`); } - const manifest = buildVoidManifest({ + const manifest = assembleVoidManifest(pageScan, { bindings: liveConfig ? { ...effectiveBindings, needsLive: true } : effectiveBindings, migrations: validatedMigrations, schedules, ssr: isSsr || hasRoutes || hasMiddleware || hasWebSockets || authEnabled, framework: isFrameworkMode ? detected!.name : undefined, revalidate, - pages, vars: envVars, queues, prerender: uniquePrerenderPaths, diff --git a/packages/void/test/integration/kitchen-sink-build.test.ts b/packages/void/test/integration/kitchen-sink-build.test.ts index c6bccf1a6..e70ce72a3 100644 --- a/packages/void/test/integration/kitchen-sink-build.test.ts +++ b/packages/void/test/integration/kitchen-sink-build.test.ts @@ -2,9 +2,8 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync import spawn from 'cross-spawn'; import { join } from 'pathe'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; -import { buildVoidManifest } from '../../src/cli/package.ts'; +import { assembleVoidManifest } from '../../src/cli/deploy.ts'; import { readConfig } from '../../src/config.ts'; -import { collectPages } from '../../src/isr-config.ts'; import { scanPages } from '../../src/pages/scan.ts'; import { resolveProjectPaths } from '../../src/project-paths.ts'; import { toSlash } from '../../src/shared/utils.ts'; @@ -130,15 +129,15 @@ describe('playground/kitchen-sink build', () => { it('emits manifest.pages for the Void pages app (scan order, RAW patterns)', async () => { // The deploy manifest (carrying `pages`) is a `void deploy` artifact, not a // `vite build` output — the `vp build` above never writes it to `dist`. Drive - // the SAME deploy-side chain `runFullDeploy` uses (scanPages → collectPages → - // buildVoidManifest) against the real kitchen-sink pages to prove the A3 wiring - // emits `manifest.pages` for a Void pages app (which includes an ISR page). + // the SAME production seam `runFullDeploy` uses (`assembleVoidManifest`, which + // owns `scanPages` result → collectPages → manifest.pages) against the real + // kitchen-sink pages to prove the A3 wiring emits `manifest.pages` for a Void + // pages app (which includes an ISR page). const pg = join(ROOT, PLAYGROUND); const deployConfig = readConfig(pg); const paths = resolveProjectPaths(pg, deployConfig); const pageScan = await scanPages(pg, { output: deployConfig.output, paths }); - const pages = collectPages(pageScan); - const manifest = buildVoidManifest({ + const manifest = assembleVoidManifest(pageScan, { bindings: { needsD1: false, needsKV: false, @@ -148,7 +147,6 @@ describe('playground/kitchen-sink build', () => { needsAI: false, needsSandbox: false, }, - pages, }); // kitchen-sink is a Void pages app → manifest.pages is emitted, in scan order. diff --git a/packages/void/test/unit/assemble-manifest.test.ts b/packages/void/test/unit/assemble-manifest.test.ts new file mode 100644 index 000000000..f009c50ea --- /dev/null +++ b/packages/void/test/unit/assemble-manifest.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { assembleVoidManifest } from '../../src/cli/deploy.ts'; +import type { InferredBindings } from '../../src/inference/walk.ts'; +import type { PageDefinition, PageScanResult } from '../../src/pages/types.ts'; + +// Minimal all-false bindings — `manifest.pages` emission is independent of +// bindings, so this is the smallest valid `buildVoidManifest` input. +const MINIMAL_BINDINGS: InferredBindings = { + needsD1: false, + needsKV: false, + needsR2: false, + needsAuth: false, + needsQueues: false, + needsAI: false, + needsSandbox: false, +}; + +function makePage(pattern: string, revalidate?: number): PageDefinition { + return { + pattern, + params: [], + catchAll: false, + componentPath: `${pattern}.vue`, + serverPath: `${pattern}.server.ts`, + methods: ['GET'], + componentId: pattern.replace(/^\//, ''), + revalidate, + ssrMode: 'auto', + prerenderMode: 'auto', + renderMode: 'server', + shouldPrerender: false, + }; +} + +function makeScan(pages: Array): PageScanResult { + return { pages, layouts: [], namedLayouts: [] }; +} + +// This is the production seam `runFullDeploy` uses to forward `pageScan` into +// `manifest.pages`. If the `pages: collectPages(pageScan)` injection is dropped +// from `assembleVoidManifest`, the first assertion below fails (`.toEqual` +// mismatch) and the second fails (`pages` stays undefined) — so this test goes +// RED the moment the wiring regresses. +describe('assembleVoidManifest', () => { + it('forwards the page scan into manifest.pages (ordered, raw patterns, ISR TTL preserved)', () => { + const pageScan = makeScan([ + makePage('/docs/setup/install'), // static, no revalidate + makePage('/blog/:slug', 120), // dynamic segment, ISR + makePage('/docs/:path{.+}', 3600), // catch-all, ISR, RAW pattern + ]); + const manifest = assembleVoidManifest(pageScan, { bindings: MINIMAL_BINDINGS }); + expect(manifest.pages).toEqual([ + { pattern: '/docs/setup/install' }, + { pattern: '/blog/:slug', revalidate: 120 }, + { pattern: '/docs/:path{.+}', revalidate: 3600 }, + ]); + }); + + it('omits manifest.pages when the scan has no pages', () => { + expect( + assembleVoidManifest(makeScan([]), { bindings: MINIMAL_BINDINGS }).pages, + ).toBeUndefined(); + expect(assembleVoidManifest(null, { bindings: MINIMAL_BINDINGS }).pages).toBeUndefined(); + }); +}); From 2ac7e62288a3c31e56faab72d96c82a23c1b0e31 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 10 Jul 2026 17:10:01 +0800 Subject: [PATCH 05/12] test(isr): drive runFullDeploy end-to-end to guard manifest.pages emission Adds an integration test that executes the real exported runDeploy -> runDeployInner -> runFullDeploy (Void/SSR path) against a pre-built minimal Void pages fixture (playground/named-layouts, plus a tiny /isr page with `export const revalidate = 60`), with only network, prompts and prerender path-discovery stubbed, and asserts the captured finalize manifest carries `pages` (including the ISR entry with its exact TTL). Closes the gap where reverting runFullDeploy's `assembleVoidManifest(pageScan, ...)` back to `buildVoidManifest({ ... })` -- which drops `pages` from real deploys -- left every test green. Verified RED-on-removal: the test fails (manifest.pages undefined) when the seam is reverted, and passes when restored. Co-Authored-By: Claude Opus 4.8 --- .../integration/full-deploy-manifest.test.ts | 233 ++++++++++++++++++ playground/named-layouts/pages/isr.server.ts | 10 + playground/named-layouts/pages/isr.tsx | 5 + 3 files changed, 248 insertions(+) create mode 100644 packages/void/test/integration/full-deploy-manifest.test.ts create mode 100644 playground/named-layouts/pages/isr.server.ts create mode 100644 playground/named-layouts/pages/isr.tsx diff --git a/packages/void/test/integration/full-deploy-manifest.test.ts b/packages/void/test/integration/full-deploy-manifest.test.ts new file mode 100644 index 000000000..964e0402b --- /dev/null +++ b/packages/void/test/integration/full-deploy-manifest.test.ts @@ -0,0 +1,233 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import spawn from 'cross-spawn'; +import { join } from 'pathe'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vite-plus/test'; + +// @clack/prompts + the output-spinner wrapper are TTY-bound; noop them (same as +// direct-r2-deploy.test.ts) so runDeploy's interactive UI doesn't render here. +const noopSpinner = { + start: vi.fn(), + stop: vi.fn(), + message: vi.fn(), +}; +vi.mock('@clack/prompts', async () => { + const actual = await vi.importActual('@clack/prompts'); + return { + ...actual, + intro: vi.fn(), + outro: vi.fn(), + note: vi.fn(), + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + step: vi.fn(), + success: vi.fn(), + }, + spinner: () => noopSpinner, + }; +}); +vi.mock('../../src/cli/output.ts', async () => { + const actual = + await vi.importActual('../../src/cli/output.ts'); + return { ...actual, createSpinner: () => noopSpinner }; +}); + +// Prerender path discovery spins Miniflare/workerd in-process. It feeds +// `manifest.prerender`, NOT `manifest.pages`, so it is orthogonal to the seam +// under test. Stub it to [] to keep this test offline, fast and deterministic — +// everything else in runFullDeploy (including assembleVoidManifest) runs for +// real. (For a non-`output:static` Void app this is the only in-process CF +// runtime the deploy path would otherwise start; all network I/O is mocked +// below.) +vi.mock('../../src/prerender.ts', async () => { + const actual = + await vi.importActual('../../src/prerender.ts'); + return { + ...actual, + collectPrerenderPaths: vi.fn(async () => [] as Array), + }; +}); + +const { runDeploy } = await import('../../src/cli/deploy.ts'); + +const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); +const PLAYGROUND = 'playground/named-layouts'; +const FIXTURE = join(ROOT, PLAYGROUND); +const PROJECT_JSON = join(FIXTURE, '.void', 'project.json'); + +const fetchMock = vi.fn(); +let originalFetch: typeof globalThis.fetch | undefined; +let savedToken: string | undefined; +let savedProject: string | undefined; +let projectJsonSnapshot: string | undefined; +let projectJsonSnapshotTaken = false; + +type Captured = { finalizeBody: { manifest?: { pages?: unknown } } | null }; + +// Minimal platform mock: report every asset as already-present so the +// upload-urls + R2 PUT phase is skipped and runFullDeploy goes straight to the +// finalize POST, whose JSON body we capture. +function installPlatformMocks(): Captured { + const captured: Captured = { finalizeBody: null }; + + fetchMock.mockImplementation(async (input: RequestInfo, init?: RequestInit) => { + const url = typeof input === 'string' ? input : (input as Request).url; + const reqInit = init ?? {}; + const method = (reqInit.method ?? 'GET').toUpperCase(); + + if (url.endsWith('/deploy/preflight') && method === 'POST') { + const body = JSON.parse(reqInit.body as string) as { + assetManifest?: Record; + }; + const total = Object.keys(body.assetManifest ?? {}).length; + return json({ needed: [], total, skipped: total }); + } + + if (url.endsWith('/deploy') && method === 'POST') { + captured.finalizeBody = JSON.parse(reqInit.body as string); + return ndjson([ + { event: 'start', deploymentId: 'dep_test' }, + { + event: 'done', + deployment: 'dep_test', + url: 'https://named-layouts.test.void.app', + urls: ['https://named-layouts.test.void.app'], + assets: 0, + workers: 1, + }, + ]); + } + + throw new Error(`unexpected fetch in test: ${method} ${url}`); + }); + + return captured; +} + +function json(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +function ndjson(events: Array): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const e of events) { + controller.enqueue(encoder.encode(`${JSON.stringify(e)}\n`)); + } + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { 'content-type': 'application/x-ndjson' }, + }); +} + +// End-to-end guard for the `assembleVoidManifest` seam: drives the REAL exported +// runDeploy → runDeployInner → runFullDeploy (Void/SSR path) against a +// pre-built minimal Void pages fixture, with only network + prompts + prerender +// stubbed, and asserts the finalize manifest carries `pages`. Reverting +// runFullDeploy's `assembleVoidManifest(pageScan, …)` back to +// `buildVoidManifest({ … })` drops `pages` and turns this RED. +describe('runFullDeploy emits manifest.pages (Void pages app, end-to-end)', () => { + beforeAll(() => { + // Real build of the minimal React pages fixture so runDeploy(skipBuild:true) + // finds dist/client/.vite/manifest.json + dist/ssr/{index.js,wrangler.json}. + const result = spawn.sync('vp', ['run', '--filter', `./${PLAYGROUND}`, 'build'], { + cwd: ROOT, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + env: { ...process.env, VOID_TOKEN: 'test-token-do-not-use' }, + }); + expect(result.status, `${result.stdout ?? ''}${result.stderr ?? ''}`).toBe(0); + + // Link the fixture to a fake project (snapshot any real link first so we + // restore developer-local state exactly on teardown). + projectJsonSnapshot = existsSync(PROJECT_JSON) + ? readFileSync(PROJECT_JSON, 'utf-8') + : undefined; + projectJsonSnapshotTaken = true; + mkdirSync(join(FIXTURE, '.void'), { recursive: true }); + writeFileSync( + PROJECT_JSON, + JSON.stringify({ projectId: 'proj_named_layouts_test', slug: 'named-layouts-test' }), + ); + }, 180_000); + + afterAll(() => { + if (!projectJsonSnapshotTaken) { + return; + } + if (projectJsonSnapshot === undefined) { + rmSync(PROJECT_JSON, { force: true }); + } else { + writeFileSync(PROJECT_JSON, projectJsonSnapshot); + } + }); + + beforeEach(() => { + originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + fetchMock.mockReset(); + + savedToken = process.env.VOID_TOKEN; + savedProject = process.env.VOID_PROJECT; + // Explicit token bypasses getToken() + the GitHub-Actions OIDC branch. + process.env.VOID_TOKEN = 'test-token-do-not-use'; + // Force resolution via .void/project.json (avoid the listProjects() path). + delete process.env.VOID_PROJECT; + }); + + afterEach(() => { + if (originalFetch) { + globalThis.fetch = originalFetch; + } + if (savedToken === undefined) { + delete process.env.VOID_TOKEN; + } else { + process.env.VOID_TOKEN = savedToken; + } + if (savedProject === undefined) { + delete process.env.VOID_PROJECT; + } else { + process.env.VOID_PROJECT = savedProject; + } + }); + + it('forwards the page scan into the finalize manifest.pages (incl. the ISR page)', async () => { + const captured = installPlatformMocks(); + + await runDeploy(FIXTURE, { skipBuild: true }); + + expect(captured.finalizeBody).not.toBeNull(); + const pages = captured.finalizeBody!.manifest?.pages as + | Array<{ pattern: string; revalidate?: number }> + | undefined; + + // manifest.pages must be a non-empty array — this is exactly what dropping + // the assembleVoidManifest seam (reverting to buildVoidManifest) removes. + expect(Array.isArray(pages)).toBe(true); + expect(pages!.length).toBeGreaterThan(0); + // Every entry carries a string pattern (raw, route-shaped). + for (const p of pages!) { + expect(typeof p.pattern).toBe('string'); + } + // The /isr page (pages/isr.server.ts → `export const revalidate = 60`) must + // survive into the real deploy manifest with its exact TTL. + expect(pages).toContainEqual({ pattern: '/isr', revalidate: 60 }); + }); +}); diff --git a/playground/named-layouts/pages/isr.server.ts b/playground/named-layouts/pages/isr.server.ts new file mode 100644 index 000000000..4c37f2c68 --- /dev/null +++ b/playground/named-layouts/pages/isr.server.ts @@ -0,0 +1,10 @@ +import { defineHandler } from 'void'; + +// ISR fixture: `export const revalidate` makes this page surface in the deploy +// manifest's `pages` table with its TTL. Exercised by the full-deploy manifest +// integration test (test/integration/full-deploy-manifest.test.ts). +export const revalidate = 60; + +export const loader = defineHandler(() => { + return {}; +}); diff --git a/playground/named-layouts/pages/isr.tsx b/playground/named-layouts/pages/isr.tsx new file mode 100644 index 000000000..483bfbcb0 --- /dev/null +++ b/playground/named-layouts/pages/isr.tsx @@ -0,0 +1,5 @@ +export const layout = false; + +export default function IsrPage() { + return
ISR
; +} From d2fff2ff9a7a8a15e887800a85e8a330b76b1ada Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 10 Jul 2026 17:27:01 +0800 Subject: [PATCH 06/12] fix(isr): emit manifest.pages only in Void pages mode (gate framework mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `pages` route-aware ISR table is a Void pages-mode concept. Class-A frameworks (@tanstack/react-start, @react-router/dev, vinext) also route through runFullDeploy, where a pages/ dir would previously get Void's `pages` table emitted for their routes — violating plan 0077's Global Constraint ("pages is Void-pages-mode only; leave frameworks alone"). Gate the emission inside the assembleVoidManifest seam on isFrameworkMode so it stays cheaply unit-testable. revalidate behavior is unchanged (collectRevalidate still consumes the same pageScan). Adds unit cases (void emits, framework omits) plus a framework-mode end-to-end variant that mocks detectFramework to force the Class-A path on a fixture with a pages/ dir and asserts manifest.pages is absent. Co-Authored-By: Claude Opus 4.8 --- packages/void/src/cli/deploy.ts | 13 +++++- .../integration/full-deploy-manifest.test.ts | 41 +++++++++++++++++++ .../integration/kitchen-sink-build.test.ts | 2 +- .../void/test/unit/assemble-manifest.test.ts | 20 +++++++-- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 9a7306661..c9688e912 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -1040,12 +1040,21 @@ function formatKnownAssetSummary(result: DeployDoneEvent, skipped: number): stri * from the page scan. This is the single production seam that forwards * `pageScan` → `manifest.pages`; `runFullDeploy` calls it so the wiring is * exercised (and regression-guarded) by a unit test. + * + * `pages` is a Void pages-mode concept. In framework mode (Class-A frameworks + * routed through `runFullDeploy`) we must NOT emit it — the framework owns its + * own routing/ISR — so `isFrameworkMode` suppresses the table. (plan 0077 + * Global Constraint: "`pages` is Void-pages-mode only; leave frameworks alone".) */ export function assembleVoidManifest( pageScan: PageScanResult | null, + isFrameworkMode: boolean, opts: Parameters[0], ): DeployManifest { - return buildVoidManifest({ ...opts, pages: collectPages(pageScan) }); + return buildVoidManifest({ + ...opts, + pages: isFrameworkMode ? undefined : collectPages(pageScan), + }); } export async function runStaticDeploy( @@ -1888,7 +1897,7 @@ async function runFullDeploy( console.warn(`[void] ${fallbackWarning}`); } - const manifest = assembleVoidManifest(pageScan, { + const manifest = assembleVoidManifest(pageScan, isFrameworkMode, { bindings: liveConfig ? { ...effectiveBindings, needsLive: true } : effectiveBindings, migrations: validatedMigrations, schedules, diff --git a/packages/void/test/integration/full-deploy-manifest.test.ts b/packages/void/test/integration/full-deploy-manifest.test.ts index 964e0402b..21757cd79 100644 --- a/packages/void/test/integration/full-deploy-manifest.test.ts +++ b/packages/void/test/integration/full-deploy-manifest.test.ts @@ -11,6 +11,7 @@ import { it, vi, } from 'vite-plus/test'; +import type { DetectedFramework } from '../../src/plugin-inference.ts'; // @clack/prompts + the output-spinner wrapper are TTY-bound; noop them (same as // direct-r2-deploy.test.ts) so runDeploy's interactive UI doesn't render here. @@ -58,6 +59,22 @@ vi.mock('../../src/prerender.ts', async () => { }; }); +// detectFramework is mocked so the SAME pre-built fixture can be driven down +// both deploy routes: the default (undefined → Void pages path, exercised by +// the first test) and, per-test, a forced Class-A framework (→ runFullDeploy +// with isFrameworkMode = true, which must suppress manifest.pages). The return +// value is set in beforeEach (undefined) and overridden inside the framework +// test — so the Void test always sees the real-world `undefined`. +const { mockDetectFramework } = vi.hoisted(() => ({ + mockDetectFramework: vi.fn<(root: string) => DetectedFramework | undefined>(), +})); +vi.mock('../../src/plugin-inference.ts', async () => { + const actual = await vi.importActual( + '../../src/plugin-inference.ts', + ); + return { ...actual, detectFramework: mockDetectFramework }; +}); + const { runDeploy } = await import('../../src/cli/deploy.ts'); const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); @@ -180,6 +197,10 @@ describe('runFullDeploy emits manifest.pages (Void pages app, end-to-end)', () = }); beforeEach(() => { + // Default: no framework detected → the Void pages path. The framework test + // overrides this to a Class-A framework before driving runDeploy. + mockDetectFramework.mockReturnValue(undefined); + originalFetch = globalThis.fetch; globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; fetchMock.mockReset(); @@ -230,4 +251,24 @@ describe('runFullDeploy emits manifest.pages (Void pages app, end-to-end)', () = // survive into the real deploy manifest with its exact TTL. expect(pages).toContainEqual({ pattern: '/isr', revalidate: 60 }); }); + + it('suppresses manifest.pages in framework mode (Class-A framework forced via detectFramework)', async () => { + // Force the SAME pre-built fixture down the Class-A framework route. The + // vinext-pages preset (workerDir dist/ssr, assetsDir dist/client, + // workerMain index.js) matches the Void React build layout exactly, so + // runDeployInner routes to runFullDeploy with isFrameworkMode = true. + mockDetectFramework.mockReturnValue({ name: 'vinext-pages', class: 'a' }); + const captured = installPlatformMocks(); + + await runDeploy(FIXTURE, { skipBuild: true }); + + // The deploy still finalizes (manifest present)... + expect(captured.finalizeBody).not.toBeNull(); + expect(captured.finalizeBody!.manifest).toBeDefined(); + // ...but `pages` (the Void pages-mode ISR table) is suppressed in framework + // mode even though the fixture has a pages/ dir with an ISR page. Removing + // the `isFrameworkMode ? undefined :` gate in assembleVoidManifest turns + // this RED (pages becomes the populated array instead of undefined). + expect(captured.finalizeBody!.manifest?.pages).toBeUndefined(); + }); }); diff --git a/packages/void/test/integration/kitchen-sink-build.test.ts b/packages/void/test/integration/kitchen-sink-build.test.ts index e70ce72a3..9d9f07d8f 100644 --- a/packages/void/test/integration/kitchen-sink-build.test.ts +++ b/packages/void/test/integration/kitchen-sink-build.test.ts @@ -137,7 +137,7 @@ describe('playground/kitchen-sink build', () => { const deployConfig = readConfig(pg); const paths = resolveProjectPaths(pg, deployConfig); const pageScan = await scanPages(pg, { output: deployConfig.output, paths }); - const manifest = assembleVoidManifest(pageScan, { + const manifest = assembleVoidManifest(pageScan, false, { bindings: { needsD1: false, needsKV: false, diff --git a/packages/void/test/unit/assemble-manifest.test.ts b/packages/void/test/unit/assemble-manifest.test.ts index f009c50ea..1b16702e7 100644 --- a/packages/void/test/unit/assemble-manifest.test.ts +++ b/packages/void/test/unit/assemble-manifest.test.ts @@ -48,7 +48,7 @@ describe('assembleVoidManifest', () => { makePage('/blog/:slug', 120), // dynamic segment, ISR makePage('/docs/:path{.+}', 3600), // catch-all, ISR, RAW pattern ]); - const manifest = assembleVoidManifest(pageScan, { bindings: MINIMAL_BINDINGS }); + const manifest = assembleVoidManifest(pageScan, false, { bindings: MINIMAL_BINDINGS }); expect(manifest.pages).toEqual([ { pattern: '/docs/setup/install' }, { pattern: '/blog/:slug', revalidate: 120 }, @@ -58,8 +58,22 @@ describe('assembleVoidManifest', () => { it('omits manifest.pages when the scan has no pages', () => { expect( - assembleVoidManifest(makeScan([]), { bindings: MINIMAL_BINDINGS }).pages, + assembleVoidManifest(makeScan([]), false, { bindings: MINIMAL_BINDINGS }).pages, ).toBeUndefined(); - expect(assembleVoidManifest(null, { bindings: MINIMAL_BINDINGS }).pages).toBeUndefined(); + expect(assembleVoidManifest(null, false, { bindings: MINIMAL_BINDINGS }).pages).toBeUndefined(); + }); + + it('emits manifest.pages for a void app (isFrameworkMode = false)', () => { + const scan = makeScan([makePage('/isr', 60)]); + expect(assembleVoidManifest(scan, false, { bindings: MINIMAL_BINDINGS }).pages).toEqual([ + { pattern: '/isr', revalidate: 60 }, + ]); + }); + + it('omits manifest.pages in framework mode even with a populated scan (isFrameworkMode = true)', () => { + // `pages` is a Void pages-mode concept: a Class-A framework routed through + // runFullDeploy must NOT emit it, even when it happens to have a pages/ dir. + const scan = makeScan([makePage('/isr', 60)]); + expect(assembleVoidManifest(scan, true, { bindings: MINIMAL_BINDINGS }).pages).toBeUndefined(); }); }); From 9148de4fb2ef027393096f84fd8e8da6b4c53494 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 11 Jul 2026 11:40:58 +0800 Subject: [PATCH 07/12] fix(isr): resolve each page's effective ISR TTL at build time (preserve void.json routing.revalidate) Co-Authored-By: Claude Opus 4.8 --- packages/void/src/cli/deploy.ts | 50 +++++--- packages/void/src/isr-config.ts | 139 ++++++++++++++++++++- packages/void/test/unit/isr-config.test.ts | 111 +++++++++++++++- 3 files changed, 273 insertions(+), 27 deletions(-) diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index c9688e912..d938ca519 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -1050,10 +1050,15 @@ export function assembleVoidManifest( pageScan: PageScanResult | null, isFrameworkMode: boolean, opts: Parameters[0], + revalidateConfig?: number | Record | undefined, ): DeployManifest { return buildVoidManifest({ ...opts, - pages: isFrameworkMode ? undefined : collectPages(pageScan), + // Bake each page's effective ISR TTL from its own `export const revalidate` + // AND the app's `void.json` `routing.revalidate` config (the RAW value, not + // the merged `collectRevalidate` map) so the `pages` table is self-sufficient + // — dispatch never falls back to the flat map for a matched page. + pages: isFrameworkMode ? undefined : collectPages(pageScan, revalidateConfig), }); } @@ -1897,25 +1902,30 @@ async function runFullDeploy( console.warn(`[void] ${fallbackWarning}`); } - const manifest = assembleVoidManifest(pageScan, isFrameworkMode, { - bindings: liveConfig ? { ...effectiveBindings, needsLive: true } : effectiveBindings, - migrations: validatedMigrations, - schedules, - ssr: isSsr || hasRoutes || hasMiddleware || hasWebSockets || authEnabled, - framework: isFrameworkMode ? detected!.name : undefined, - revalidate, - vars: envVars, - queues, - prerender: uniquePrerenderPaths, - assetConfig, - options: voidPackageOptions, - hashedAssetsPrefix: assetsPrefix, - headerRules, - redirectRules, - fallbackRules, - dialect, - webSockets, - }); + const manifest = assembleVoidManifest( + pageScan, + isFrameworkMode, + { + bindings: liveConfig ? { ...effectiveBindings, needsLive: true } : effectiveBindings, + migrations: validatedMigrations, + schedules, + ssr: isSsr || hasRoutes || hasMiddleware || hasWebSockets || authEnabled, + framework: isFrameworkMode ? detected!.name : undefined, + revalidate, + vars: envVars, + queues, + prerender: uniquePrerenderPaths, + assetConfig, + options: voidPackageOptions, + hashedAssetsPrefix: assetsPrefix, + headerRules, + redirectRules, + fallbackRules, + dialect, + webSockets, + }, + deployConfig.routing?.revalidate, + ); const workerDir = join(distDir, workerDirName); if (isFrameworkMode) { const wrapperFeatures: WrapperFeatures = { diff --git a/packages/void/src/isr-config.ts b/packages/void/src/isr-config.ts index db3bc01d8..a692a6cf2 100644 --- a/packages/void/src/isr-config.ts +++ b/packages/void/src/isr-config.ts @@ -1,5 +1,5 @@ import type { VoidConfig } from './config.ts'; -import type { PageScanResult } from './pages/types.ts'; +import type { PageDefinition, PageScanResult } from './pages/types.ts'; /** * Whether a resolved `revalidate` value represents an active ISR configuration. @@ -84,6 +84,125 @@ export function collectRevalidate( return revalidate; } +// Private-use sentinels used while compiling a config pattern to a RegExp. +// Identical scheme to dispatch's `matchSegmentPattern` (see `matchConfigPattern`). +const SEGMENT_SENTINEL = String.fromCharCode(0xe000); +const CATCHALL_SENTINEL = String.fromCharCode(0xe001); + +/** + * Compile a `void.json` `routing.revalidate` CONFIG pattern to a RegExp and + * test a pathname against it, using the EXACT segment-anchored semantics + * dispatch applies at runtime (`matchSegmentPattern`, + * `packages/dispatch/src/pattern.ts`). Parity here is load-bearing: the TTL we + * bake into a page at build time MUST equal what dispatch's + * `resolvePathTtl(config, )` resolves at the edge, or we + * ship a silently wrong TTL. This is a faithful port of that matcher (minus its + * LRU cache — a runtime hot-path concern only). + * + * We deliberately do NOT reuse the framework's `patternToRegex` + * (`pages/manifest.ts`): despite looking similar it is the CLIENT route matcher + * and DIVERGES from dispatch on the two cases ISR configs care about most — + * (1) it injects `(.+)` for a `:name{.+}` catch-all BEFORE its regex-escape + * step, so the escape turns it into a literal `\(\.\+\)` that never matches a + * real path, and (2) its `*` replace lacks the `/g` flag so only the FIRST `*` + * in a pattern expands. Both would break parity on the exact patterns ISR + * configs use (`/docs/:path{.+}`, multi-`*`). The parity cross-check test pins + * this matcher to the parent repo's `matchSegmentPattern`. (Exported for that + * cross-check test; not part of the package's public exports.) + */ +export function matchConfigPattern(pattern: string, pathname: string): boolean { + let regex: RegExp; + try { + const escaped = pattern + .replace(/(^|\/):[^/{}]+\{\.\+\}(?=\/|$)/g, `$1${CATCHALL_SENTINEL}`) // named catch-all :name{.+} + .replace(/(^|\/):[^/]+(?=\/|$)/g, `$1${SEGMENT_SENTINEL}`) // whole-segment :name + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // escape regex specials + .replaceAll('*', '.*') // legacy `*` crosses '/' + .replaceAll(SEGMENT_SENTINEL, '[^/]+') // one segment + .replaceAll(CATCHALL_SENTINEL, '.+'); // >=1 char, crosses '/' + regex = new RegExp(`^${escaped}$`); + } catch { + return false; + } + return regex.test(pathname); +} + +/** + * Substitute a page pattern's params to a concrete probe path so it can be + * tested against config patterns. Each placeholder becomes a value the page's + * OWN pattern would match: + * `:name` -> one segment (`_`) + * `:name{.+}` -> a value crossing `/` (`_/_`) (named catch-all) + * `*` -> a value crossing `/` (`_/_`) (anonymous catch-all) + * Literal segments pass through untouched. The named catch-all is substituted + * FIRST (same ordering hazard as `matchSegmentPattern`) so `:name{.+}` is + * consumed as a unit before the plain `:name` rule can grab it and strand the + * `{.+}` / pick the wrong single-segment value. This is the one slightly-hacky + * mechanic — kept small: it only builds a representative path, never persisted. + */ +function pagePatternToProbePath(pattern: string): string { + return pattern + .replace(/(^|\/):[^/{}]+\{\.\+\}(?=\/|$)/g, '$1_/_') // named catch-all -> crossing value + .replace(/(^|\/):[^/]+(?=\/|$)/g, '$1_') // single-segment param -> one segment + .replaceAll('*', '_/_'); // anonymous catch-all -> crossing value +} + +/** + * Resolve a single page's effective ISR TTL at BUILD time so the emitted + * `pages` table is SELF-SUFFICIENT: dispatch's `resolvePageTtl` first-matches + * the table and, on a match, uses ONLY that page's baked TTL — it never + * consults the flat `revalidate` map for a matched page. Before this, a page + * with no per-page `export const revalidate` baked no TTL, so a Void Pages app + * that configured ISR through `void.json` `routing.revalidate` silently lost + * ISR once the `pages` table shipped (worst case: a global-number config turned + * page ISR OFF for the entire site). + * + * Order — SOURCE-SEPARATED, which is what preserves the plan-0077 catch-all + * shadowing fix (a no-export page must NOT inherit a SIBLING page's per-page + * TTL): + * 1. an explicit per-page `export const revalidate` is authoritative — a + * positive value turns ISR on; an explicit 0 opts the page OUT and is NOT + * overridden by the app-wide config below; + * 2. else resolve against the app's `void.json` `routing.revalidate` config + * ONLY (never the merged `collectRevalidate` map, never another page's + * export): + * - number -> that global TTL (applies to every page); + * - object -> the FIRST config pattern (dispatch first-match order) that + * matches the page's representative probe path; else 0; + * - absent -> 0. + * + * Accepted residual (do NOT try to fix): a single page pattern that straddles + * MULTIPLE distinct config patterns (e.g. a root catch-all `/:path{.+}` with + * config `{ '/blog/*': 3600, '/news/*': 1800 }`) can carry only ONE TTL — the + * probe is a single representative path, so only the config pattern covering + * that probe applies. This is a fundamental per-path-vs-per-page expressiveness + * gap; it can only UNDER-cache (never wrong-serve) and is strictly better than + * the pre-fix status quo where such a page lost ISR entirely. + */ +function resolvePageBuildTtl( + page: PageDefinition, + revalidateConfig: number | Record | undefined, +): number { + // 1. Explicit per-page export wins (0 = explicit opt-out, preserved). + if (page.revalidate != null) { + return page.revalidate > 0 ? page.revalidate : 0; + } + // 2. App-wide void.json `routing.revalidate` config ONLY. + if (typeof revalidateConfig === 'number') { + return revalidateConfig > 0 ? revalidateConfig : 0; + } + if (revalidateConfig != null && typeof revalidateConfig === 'object') { + const probe = pagePatternToProbePath(page.pattern); + for (const [pattern, ttl] of Object.entries(revalidateConfig)) { + // First matching config pattern wins — mirrors dispatch `resolvePathTtl`. + if (matchConfigPattern(pattern, probe)) { + return ttl > 0 ? ttl : 0; + } + } + } + return 0; +} + /** * Build the ordered page-route table shipped in the deploy manifest as * `pages`. Emits EVERY page route (with or without a TTL) in `pageScan.pages` @@ -93,18 +212,26 @@ export function collectRevalidate( * matcher (`matchSegmentPattern`) handles `:name{.+}` natively, so — unlike the * flat `revalidate` map — catch-alls are NOT collapsed to `*`. * + * Each page's `revalidate` is resolved at BUILD time (`resolvePageBuildTtl`) + * from its own `export const revalidate` first, then the app's `void.json` + * `routing.revalidate` config (`revalidateConfig`, the RAW config value — NOT + * the merged `collectRevalidate` map). This makes the table self-sufficient: + * dispatch's `resolvePageTtl` never has to fall back to the flat map for a + * matched page (a matched page with no baked TTL resolves to 0), so a Void Pages + * app that configures ISR only through `routing.revalidate` keeps its ISR. + * * Consumers (dispatch, deploy prewarm) first-match over this list to pick the * winning route, then apply that route's `revalidate` (0/absent = not ISR). */ export function collectPages( pageScan: PageScanResult | null, + revalidateConfig?: number | Record | undefined, ): Array<{ pattern: string; revalidate?: number }> | undefined { if (!pageScan || pageScan.pages.length === 0) { return undefined; } - return pageScan.pages.map((page) => - page.revalidate != null - ? { pattern: page.pattern, revalidate: page.revalidate } - : { pattern: page.pattern }, - ); + return pageScan.pages.map((page) => { + const ttl = resolvePageBuildTtl(page, revalidateConfig); + return ttl > 0 ? { pattern: page.pattern, revalidate: ttl } : { pattern: page.pattern }; + }); } diff --git a/packages/void/test/unit/isr-config.test.ts b/packages/void/test/unit/isr-config.test.ts index 358dfc93d..fb55b41e9 100644 --- a/packages/void/test/unit/isr-config.test.ts +++ b/packages/void/test/unit/isr-config.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from 'vite-plus/test'; +// Cross-repo import: the platform dispatch worker's runtime matcher. The +// framework's build-time `matchConfigPattern` MUST resolve config patterns +// identically, or the baked ISR TTL diverges from what dispatch serves. Kept as +// a real import (not a copied matrix) so the parity check tracks the source. +import { matchSegmentPattern } from '../../../../../packages/dispatch/src/pattern.ts'; import type { VoidConfig } from '../../src/config.ts'; -import { collectPages, collectRevalidate, hasRevalidate } from '../../src/isr-config.ts'; +import { + collectPages, + collectRevalidate, + hasRevalidate, + matchConfigPattern, +} from '../../src/isr-config.ts'; import type { PageDefinition, PageScanResult } from '../../src/pages/types.ts'; import { resolvePathTtl } from '../../src/runtime/isr-cache.ts'; @@ -171,4 +181,103 @@ describe('collectPages', () => { { pattern: '/docs/:path{.+}', revalidate: 3600 }, ]); }); + + // Test 1 — a per-page `export const revalidate` is authoritative and wins over + // ANY void.json routing.revalidate config (number or object). + it('per-page export wins over config', () => { + const scan = makeScan([makePage('/p', 120)]); + expect(collectPages(scan, 3600)).toEqual([{ pattern: '/p', revalidate: 120 }]); + expect(collectPages(scan, { '/p': 9999 })).toEqual([{ pattern: '/p', revalidate: 120 }]); + }); + + // An explicit `export const revalidate = 0` is an opt-OUT and must NOT be + // overridden by an app-wide config (preserves pre-#272 opt-out semantics). + it('explicit per-page revalidate=0 opts out and is not overridden by config', () => { + const scan = makeScan([makePage('/p', 0)]); + expect(collectPages(scan, 3600)).toEqual([{ pattern: '/p' }]); + }); + + // Test 2 — REGRESSION GUARD (the must-fix): a global-number config must turn + // ISR ON for EVERY no-export page. Before this fix, such pages baked no TTL and + // dispatch's resolvePageTtl returned 0 → page ISR silently OFF for the whole site. + it('a global-number config applies to every no-export page', () => { + const scan = makeScan([makePage('/blog/:slug'), makePage('/about')]); + expect(collectPages(scan, 3600)).toEqual([ + { pattern: '/blog/:slug', revalidate: 3600 }, + { pattern: '/about', revalidate: 3600 }, + ]); + }); + + // Test 3 — an object config is resolved per-page against the config patterns + // ONLY; a page not covered by any config pattern stays 0 (no ISR). + it('an object config applies to matching no-export pages only', () => { + const scan = makeScan([makePage('/blog/:slug'), makePage('/about')]); + expect(collectPages(scan, { '/blog/*': 3600 })).toEqual([ + { pattern: '/blog/:slug', revalidate: 3600 }, + { pattern: '/about' }, // not covered by config → no revalidate (0) + ]); + }); + + // Test 4 — SHADOWING PRESERVED (plan 0077): a specific no-export page under a + // SIBLING catch-all that HAS an export must NOT inherit the sibling's per-page + // TTL. With no matching config it stays 0; with a config that covers it, it + // legitimately resolves from the config. + it('a no-export page does not inherit a sibling catch-all export (config absent)', () => { + const scan = makeScan([makePage('/docs/:path{.+}', 300), makePage('/docs/setup/install')]); + expect(collectPages(scan)).toEqual([ + { pattern: '/docs/:path{.+}', revalidate: 300 }, + { pattern: '/docs/setup/install' }, // stays 0 — no sibling inheritance + ]); + }); + + it('a no-export page DOES resolve from the config when the config covers it', () => { + const scan = makeScan([makePage('/docs/:path{.+}', 300), makePage('/docs/setup/install')]); + expect(collectPages(scan, { '/docs/*': 300 })).toEqual([ + { pattern: '/docs/:path{.+}', revalidate: 300 }, + { pattern: '/docs/setup/install', revalidate: 300 }, // from config, legitimately + ]); + }); +}); + +// Test 5 — PARITY: the build-time config-pattern matcher MUST agree with the +// platform dispatch runtime matcher (`matchSegmentPattern`) on every pattern an +// ISR config can use. If they diverge, the TTL baked into the `pages` table +// won't equal what dispatch resolves at the edge → silent wrong-TTL. This pins +// the framework matcher to the imported parent source across a matrix that +// exercises single-segment params, catch-alls, multi-`*`, and literals (dotted). +describe('matchConfigPattern parity with dispatch matchSegmentPattern', () => { + const patterns = [ + '/blog/:slug', + '/blog/*', + '/docs/:path{.+}', + '/shop/:cat/:id', + '/x/*/y/*', + '/a.b', + '/about', + ]; + const paths = [ + '/blog/hello', + '/blog/a/b', + '/docs/x', + '/docs/x/y', + '/shop/a/1', + '/x/1/y/2', + '/a.b', + '/axb', + '/about', + '/blog', + '/docs', + ]; + + it('agrees with matchSegmentPattern across the full pattern × path matrix', () => { + const divergences: Array = []; + for (const pattern of patterns) { + for (const path of paths) { + if (matchConfigPattern(pattern, path) !== matchSegmentPattern(pattern, path)) { + divergences.push(`pattern=${JSON.stringify(pattern)} path=${JSON.stringify(path)}`); + } + } + } + expect(divergences).toEqual([]); + }); }); From 015d6e60f9ce1cb42b632c975257b24794df52f7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 11 Jul 2026 11:52:01 +0800 Subject: [PATCH 08/12] test(isr): self-contained matchConfigPattern parity (golden table, no cross-repo import) The parity test imported matchSegmentPattern live from the platform repo (../../../../../packages/dispatch/src/pattern.ts). That path only resolves in the void-platform submodule nesting; in the framework's OWN standalone CI it is absent, so tsgo failed with TS2307 and broke test:all. Replace the live import + dynamic cross-check with an explicit golden truth table of dispatch's segment-anchored semantics (single-segment param, named catch-all + '*' crossing '/', multi-'*', literal dot, and the greedy ':name.ext' single-segment case that mirrors dispatch's '/blog/:slug.xml'). Self-contained; keep in sync with matchSegmentPattern if its semantics change. Co-Authored-By: Claude Opus 4.8 --- packages/void/test/unit/isr-config.test.ts | 87 ++++++++++++---------- 1 file changed, 47 insertions(+), 40 deletions(-) diff --git a/packages/void/test/unit/isr-config.test.ts b/packages/void/test/unit/isr-config.test.ts index fb55b41e9..327ea3b23 100644 --- a/packages/void/test/unit/isr-config.test.ts +++ b/packages/void/test/unit/isr-config.test.ts @@ -1,9 +1,4 @@ import { describe, expect, it } from 'vite-plus/test'; -// Cross-repo import: the platform dispatch worker's runtime matcher. The -// framework's build-time `matchConfigPattern` MUST resolve config patterns -// identically, or the baked ISR TTL diverges from what dispatch serves. Kept as -// a real import (not a copied matrix) so the parity check tracks the source. -import { matchSegmentPattern } from '../../../../../packages/dispatch/src/pattern.ts'; import type { VoidConfig } from '../../src/config.ts'; import { collectPages, @@ -240,44 +235,56 @@ describe('collectPages', () => { }); // Test 5 — PARITY: the build-time config-pattern matcher MUST agree with the -// platform dispatch runtime matcher (`matchSegmentPattern`) on every pattern an -// ISR config can use. If they diverge, the TTL baked into the `pages` table -// won't equal what dispatch resolves at the edge → silent wrong-TTL. This pins -// the framework matcher to the imported parent source across a matrix that -// exercises single-segment params, catch-alls, multi-`*`, and literals (dotted). -describe('matchConfigPattern parity with dispatch matchSegmentPattern', () => { - const patterns = [ - '/blog/:slug', - '/blog/*', - '/docs/:path{.+}', - '/shop/:cat/:id', - '/x/*/y/*', - '/a.b', - '/about', - ]; - const paths = [ - '/blog/hello', - '/blog/a/b', - '/docs/x', - '/docs/x/y', - '/shop/a/1', - '/x/1/y/2', - '/a.b', - '/axb', - '/about', - '/blog', - '/docs', +// platform dispatch runtime matcher (`matchSegmentPattern`, +// `packages/dispatch/src/pattern.ts`) on every pattern an ISR config can use. If +// they diverge, the TTL baked into the `pages` table won't equal what dispatch +// resolves at the edge → silent wrong-TTL. The framework repo cannot import the +// platform matcher in its standalone CI, so this pins `matchConfigPattern` to an +// EXPLICIT golden truth table of dispatch's segment-anchored semantics: +// `:name` matches ONE segment, `:name{.+}` and `*` cross `/`, a literal dot is +// literal, patterns are fully anchored. Keep in sync if those semantics change. +describe('matchConfigPattern parity with dispatch matchSegmentPattern (golden)', () => { + const cases: Array<[string, string, boolean]> = [ + // single-segment param — must NOT cross '/' + ['/blog/:slug', '/blog/hello', true], + ['/blog/:slug', '/blog/a/b', false], + ['/blog/:slug', '/blog', false], + // anonymous catch-all '*' — crosses '/' + ['/blog/*', '/blog/hello', true], + ['/blog/*', '/blog/a/b', true], + ['/blog/*', '/blog', false], + // named catch-all ':name{.+}' — crosses '/' (the case client patternToRegex gets wrong) + ['/docs/:path{.+}', '/docs/x', true], + ['/docs/:path{.+}', '/docs/x/y', true], + ['/docs/:path{.+}', '/docs', false], + // multiple params / multiple '*' (a non-/g '*' replace would break the second '*') + ['/shop/:cat/:id', '/shop/a/1', true], + ['/shop/:cat/:id', '/shop/a', false], + ['/x/*/y/*', '/x/1/y/2', true], + ['/x/*/y/*', '/x/1/2', false], + // literal dot is a literal, not "any char" + ['/a.b', '/a.b', true], + ['/a.b', '/axb', false], + // a ':name.ext' param GREEDILY swallows the '.ext' into the single-segment + // param (matches dispatch — cf. '/blog/:slug.xml' → '^/blog/[^/]+$'); it is + // NOT a literal-suffix match, but it still must not cross '/'. + ['/files/:name.json', '/files/a.json', true], + ['/files/:name.json', '/files/anything', true], + ['/files/:name.json', '/files/a/b', false], + // literal + ['/about', '/about', true], + ['/about', '/about/x', false], ]; - it('agrees with matchSegmentPattern across the full pattern × path matrix', () => { - const divergences: Array = []; - for (const pattern of patterns) { - for (const path of paths) { - if (matchConfigPattern(pattern, path) !== matchSegmentPattern(pattern, path)) { - divergences.push(`pattern=${JSON.stringify(pattern)} path=${JSON.stringify(path)}`); - } + it('matches dispatch segment-anchored semantics across the golden matrix', () => { + const wrong: Array = []; + for (const [pattern, path, expected] of cases) { + if (matchConfigPattern(pattern, path) !== expected) { + wrong.push( + `pattern=${JSON.stringify(pattern)} path=${JSON.stringify(path)} expected=${expected}`, + ); } } - expect(divergences).toEqual([]); + expect(wrong).toEqual([]); }); }); From 6c84480fd6368dcab0404e8a45f82f354dccbf23 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 12 Jul 2026 00:43:39 +0800 Subject: [PATCH 09/12] fix(isr): stop baking object routing.revalidate into per-page TTLs (per-path 0 opt-out safety) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interim build-time fix (9148de4f) baked an OBJECT routing.revalidate by first-matching each page against a single representative probe path. That is unsafe: a page pattern can straddle several object entries — including a per-path `0` opt-out. Example: page /blog/:slug under { '/blog/private': 0, '/blog/*': 3600 } → probe /blog/_ misses the 0 and bakes 3600, over-caching /blog/private despite the explicit opt-out — a wrong-serve, strictly worse than under-caching. Drop object-config baking entirely. resolvePageBuildTtl now bakes only: (1) a per-page `export const revalidate` (0 = authoritative opt-out), else (2) a global-NUMBER routing.revalidate (whole-site), else 0. Removed the probe machinery (matchConfigPattern, pagePatternToProbePath, sentinels) and its parity cross-check test. Accepted limitation (documented in plan 0077): a pages-mode app that configures ISR via an OBJECT routing.revalidate gets no page ISR through the table (matched pages resolve 0). Per-page exports (baked, correct) and global-number config (baked, correct) are the supported levers. This trades one niche non-idiomatic config's ISR for provable correctness — nothing is collapsed, so a per-path 0 opt-out can never be violated. Co-Authored-By: Claude Opus 4.8 --- packages/void/src/isr-config.ts | 123 +++++---------------- packages/void/test/unit/isr-config.test.ts | 98 ++++------------ 2 files changed, 46 insertions(+), 175 deletions(-) diff --git a/packages/void/src/isr-config.ts b/packages/void/src/isr-config.ts index a692a6cf2..198e5c8ba 100644 --- a/packages/void/src/isr-config.ts +++ b/packages/void/src/isr-config.ts @@ -84,100 +84,33 @@ export function collectRevalidate( return revalidate; } -// Private-use sentinels used while compiling a config pattern to a RegExp. -// Identical scheme to dispatch's `matchSegmentPattern` (see `matchConfigPattern`). -const SEGMENT_SENTINEL = String.fromCharCode(0xe000); -const CATCHALL_SENTINEL = String.fromCharCode(0xe001); - -/** - * Compile a `void.json` `routing.revalidate` CONFIG pattern to a RegExp and - * test a pathname against it, using the EXACT segment-anchored semantics - * dispatch applies at runtime (`matchSegmentPattern`, - * `packages/dispatch/src/pattern.ts`). Parity here is load-bearing: the TTL we - * bake into a page at build time MUST equal what dispatch's - * `resolvePathTtl(config, )` resolves at the edge, or we - * ship a silently wrong TTL. This is a faithful port of that matcher (minus its - * LRU cache — a runtime hot-path concern only). - * - * We deliberately do NOT reuse the framework's `patternToRegex` - * (`pages/manifest.ts`): despite looking similar it is the CLIENT route matcher - * and DIVERGES from dispatch on the two cases ISR configs care about most — - * (1) it injects `(.+)` for a `:name{.+}` catch-all BEFORE its regex-escape - * step, so the escape turns it into a literal `\(\.\+\)` that never matches a - * real path, and (2) its `*` replace lacks the `/g` flag so only the FIRST `*` - * in a pattern expands. Both would break parity on the exact patterns ISR - * configs use (`/docs/:path{.+}`, multi-`*`). The parity cross-check test pins - * this matcher to the parent repo's `matchSegmentPattern`. (Exported for that - * cross-check test; not part of the package's public exports.) - */ -export function matchConfigPattern(pattern: string, pathname: string): boolean { - let regex: RegExp; - try { - const escaped = pattern - .replace(/(^|\/):[^/{}]+\{\.\+\}(?=\/|$)/g, `$1${CATCHALL_SENTINEL}`) // named catch-all :name{.+} - .replace(/(^|\/):[^/]+(?=\/|$)/g, `$1${SEGMENT_SENTINEL}`) // whole-segment :name - .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // escape regex specials - .replaceAll('*', '.*') // legacy `*` crosses '/' - .replaceAll(SEGMENT_SENTINEL, '[^/]+') // one segment - .replaceAll(CATCHALL_SENTINEL, '.+'); // >=1 char, crosses '/' - regex = new RegExp(`^${escaped}$`); - } catch { - return false; - } - return regex.test(pathname); -} - -/** - * Substitute a page pattern's params to a concrete probe path so it can be - * tested against config patterns. Each placeholder becomes a value the page's - * OWN pattern would match: - * `:name` -> one segment (`_`) - * `:name{.+}` -> a value crossing `/` (`_/_`) (named catch-all) - * `*` -> a value crossing `/` (`_/_`) (anonymous catch-all) - * Literal segments pass through untouched. The named catch-all is substituted - * FIRST (same ordering hazard as `matchSegmentPattern`) so `:name{.+}` is - * consumed as a unit before the plain `:name` rule can grab it and strand the - * `{.+}` / pick the wrong single-segment value. This is the one slightly-hacky - * mechanic — kept small: it only builds a representative path, never persisted. - */ -function pagePatternToProbePath(pattern: string): string { - return pattern - .replace(/(^|\/):[^/{}]+\{\.\+\}(?=\/|$)/g, '$1_/_') // named catch-all -> crossing value - .replace(/(^|\/):[^/]+(?=\/|$)/g, '$1_') // single-segment param -> one segment - .replaceAll('*', '_/_'); // anonymous catch-all -> crossing value -} - /** * Resolve a single page's effective ISR TTL at BUILD time so the emitted * `pages` table is SELF-SUFFICIENT: dispatch's `resolvePageTtl` first-matches * the table and, on a match, uses ONLY that page's baked TTL — it never * consults the flat `revalidate` map for a matched page. Before this, a page * with no per-page `export const revalidate` baked no TTL, so a Void Pages app - * that configured ISR through `void.json` `routing.revalidate` silently lost - * ISR once the `pages` table shipped (worst case: a global-number config turned - * page ISR OFF for the entire site). + * that configured ISR through a global-number `void.json` `routing.revalidate` + * silently lost ISR once the `pages` table shipped (page ISR OFF site-wide). * - * Order — SOURCE-SEPARATED, which is what preserves the plan-0077 catch-all - * shadowing fix (a no-export page must NOT inherit a SIBLING page's per-page - * TTL): + * Two sources, in order — SOURCE-SEPARATED, which is what preserves the + * plan-0077 catch-all shadowing fix (a no-export page must NOT inherit a + * SIBLING page's per-page TTL): * 1. an explicit per-page `export const revalidate` is authoritative — a * positive value turns ISR on; an explicit 0 opts the page OUT and is NOT * overridden by the app-wide config below; - * 2. else resolve against the app's `void.json` `routing.revalidate` config - * ONLY (never the merged `collectRevalidate` map, never another page's - * export): - * - number -> that global TTL (applies to every page); - * - object -> the FIRST config pattern (dispatch first-match order) that - * matches the page's representative probe path; else 0; - * - absent -> 0. + * 2. else a global-NUMBER `void.json` `routing.revalidate` (applies to every + * page); absent -> 0. * - * Accepted residual (do NOT try to fix): a single page pattern that straddles - * MULTIPLE distinct config patterns (e.g. a root catch-all `/:path{.+}` with - * config `{ '/blog/*': 3600, '/news/*': 1800 }`) can carry only ONE TTL — the - * probe is a single representative path, so only the config pattern covering - * that probe applies. This is a fundamental per-path-vs-per-page expressiveness - * gap; it can only UNDER-cache (never wrong-serve) and is strictly better than - * the pre-fix status quo where such a page lost ISR entirely. + * An OBJECT (per-path map) `routing.revalidate` is deliberately NOT baked. It is + * a per-PATH rule set, and a single page pattern can straddle several of its + * entries — INCLUDING an explicit `0` opt-out on one of them (e.g. page + * `/blog/:slug` under `{ '/blog/private': 0, '/blog/*': 3600 }`). Collapsing that + * to one per-page TTL via a single representative probe path cannot honor the + * `0` and would OVER-cache a path the user opted out of — a correctness + * violation, not a mere under-cache. So an object-configured pages-mode app does + * not drive page ISR through the table (matched pages resolve 0); per-page + * `export const revalidate` is the supported per-route mechanism. See plan 0077. */ function resolvePageBuildTtl( page: PageDefinition, @@ -187,19 +120,12 @@ function resolvePageBuildTtl( if (page.revalidate != null) { return page.revalidate > 0 ? page.revalidate : 0; } - // 2. App-wide void.json `routing.revalidate` config ONLY. + // 2. App-wide void.json `routing.revalidate` — global NUMBER only. An object + // (per-path) config is intentionally not collapsed to a per-page TTL: it + // cannot faithfully honor a per-path `0` opt-out (see the doc comment). if (typeof revalidateConfig === 'number') { return revalidateConfig > 0 ? revalidateConfig : 0; } - if (revalidateConfig != null && typeof revalidateConfig === 'object') { - const probe = pagePatternToProbePath(page.pattern); - for (const [pattern, ttl] of Object.entries(revalidateConfig)) { - // First matching config pattern wins — mirrors dispatch `resolvePathTtl`. - if (matchConfigPattern(pattern, probe)) { - return ttl > 0 ? ttl : 0; - } - } - } return 0; } @@ -213,12 +139,13 @@ function resolvePageBuildTtl( * flat `revalidate` map — catch-alls are NOT collapsed to `*`. * * Each page's `revalidate` is resolved at BUILD time (`resolvePageBuildTtl`) - * from its own `export const revalidate` first, then the app's `void.json` + * from its own `export const revalidate` first, then a global-NUMBER `void.json` * `routing.revalidate` config (`revalidateConfig`, the RAW config value — NOT - * the merged `collectRevalidate` map). This makes the table self-sufficient: - * dispatch's `resolvePageTtl` never has to fall back to the flat map for a - * matched page (a matched page with no baked TTL resolves to 0), so a Void Pages - * app that configures ISR only through `routing.revalidate` keeps its ISR. + * the merged `collectRevalidate` map; an OBJECT per-path config is not baked, + * see `resolvePageBuildTtl`). This makes the table self-sufficient: dispatch's + * `resolvePageTtl` never has to fall back to the flat map for a matched page (a + * matched page with no baked TTL resolves to 0), so a Void Pages app that + * configures ISR through a global-number `routing.revalidate` keeps its ISR. * * Consumers (dispatch, deploy prewarm) first-match over this list to pick the * winning route, then apply that route's `revalidate` (0/absent = not ISR). diff --git a/packages/void/test/unit/isr-config.test.ts b/packages/void/test/unit/isr-config.test.ts index 327ea3b23..1b44b911d 100644 --- a/packages/void/test/unit/isr-config.test.ts +++ b/packages/void/test/unit/isr-config.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from 'vite-plus/test'; import type { VoidConfig } from '../../src/config.ts'; -import { - collectPages, - collectRevalidate, - hasRevalidate, - matchConfigPattern, -} from '../../src/isr-config.ts'; +import { collectPages, collectRevalidate, hasRevalidate } from '../../src/isr-config.ts'; import type { PageDefinition, PageScanResult } from '../../src/pages/types.ts'; import { resolvePathTtl } from '../../src/runtime/isr-cache.ts'; @@ -203,88 +198,37 @@ describe('collectPages', () => { ]); }); - // Test 3 — an object config is resolved per-page against the config patterns - // ONLY; a page not covered by any config pattern stays 0 (no ISR). - it('an object config applies to matching no-export pages only', () => { + // Test 3 — an OBJECT (per-path) config is deliberately NOT baked into per-page + // TTLs. A page pattern can straddle several config entries (incl. a per-path + // `0` opt-out), so collapsing it to one per-page TTL cannot honor the opt-out + // and would over-cache. Object-configured pages therefore bake NO TTL (resolve + // 0 at the edge); per-page `export const revalidate` is the per-route lever. + it('an object config is NOT baked into per-page TTLs (per-path, cannot collapse)', () => { const scan = makeScan([makePage('/blog/:slug'), makePage('/about')]); expect(collectPages(scan, { '/blog/*': 3600 })).toEqual([ - { pattern: '/blog/:slug', revalidate: 3600 }, - { pattern: '/about' }, // not covered by config → no revalidate (0) + { pattern: '/blog/:slug' }, // object config not baked → no revalidate (0) + { pattern: '/about' }, + ]); + }); + + // A per-path `0` opt-out inside an object config is exactly why object configs + // are not baked: a naive probe-based collapse would miss the `/blog/private: 0` + // rule and over-cache. Nothing is baked, so the opt-out can never be violated. + it('does not bake — and so never violates — a per-path 0 opt-out in an object config', () => { + const scan = makeScan([makePage('/blog/:slug')]); + expect(collectPages(scan, { '/blog/private': 0, '/blog/*': 3600 })).toEqual([ + { pattern: '/blog/:slug' }, // no TTL baked; /blog/private stays opted out at the edge ]); }); // Test 4 — SHADOWING PRESERVED (plan 0077): a specific no-export page under a // SIBLING catch-all that HAS an export must NOT inherit the sibling's per-page - // TTL. With no matching config it stays 0; with a config that covers it, it - // legitimately resolves from the config. - it('a no-export page does not inherit a sibling catch-all export (config absent)', () => { + // TTL. It stays 0 — the table never folds a sibling's per-page export in. + it('a no-export page does not inherit a sibling catch-all export', () => { const scan = makeScan([makePage('/docs/:path{.+}', 300), makePage('/docs/setup/install')]); expect(collectPages(scan)).toEqual([ { pattern: '/docs/:path{.+}', revalidate: 300 }, { pattern: '/docs/setup/install' }, // stays 0 — no sibling inheritance ]); }); - - it('a no-export page DOES resolve from the config when the config covers it', () => { - const scan = makeScan([makePage('/docs/:path{.+}', 300), makePage('/docs/setup/install')]); - expect(collectPages(scan, { '/docs/*': 300 })).toEqual([ - { pattern: '/docs/:path{.+}', revalidate: 300 }, - { pattern: '/docs/setup/install', revalidate: 300 }, // from config, legitimately - ]); - }); -}); - -// Test 5 — PARITY: the build-time config-pattern matcher MUST agree with the -// platform dispatch runtime matcher (`matchSegmentPattern`, -// `packages/dispatch/src/pattern.ts`) on every pattern an ISR config can use. If -// they diverge, the TTL baked into the `pages` table won't equal what dispatch -// resolves at the edge → silent wrong-TTL. The framework repo cannot import the -// platform matcher in its standalone CI, so this pins `matchConfigPattern` to an -// EXPLICIT golden truth table of dispatch's segment-anchored semantics: -// `:name` matches ONE segment, `:name{.+}` and `*` cross `/`, a literal dot is -// literal, patterns are fully anchored. Keep in sync if those semantics change. -describe('matchConfigPattern parity with dispatch matchSegmentPattern (golden)', () => { - const cases: Array<[string, string, boolean]> = [ - // single-segment param — must NOT cross '/' - ['/blog/:slug', '/blog/hello', true], - ['/blog/:slug', '/blog/a/b', false], - ['/blog/:slug', '/blog', false], - // anonymous catch-all '*' — crosses '/' - ['/blog/*', '/blog/hello', true], - ['/blog/*', '/blog/a/b', true], - ['/blog/*', '/blog', false], - // named catch-all ':name{.+}' — crosses '/' (the case client patternToRegex gets wrong) - ['/docs/:path{.+}', '/docs/x', true], - ['/docs/:path{.+}', '/docs/x/y', true], - ['/docs/:path{.+}', '/docs', false], - // multiple params / multiple '*' (a non-/g '*' replace would break the second '*') - ['/shop/:cat/:id', '/shop/a/1', true], - ['/shop/:cat/:id', '/shop/a', false], - ['/x/*/y/*', '/x/1/y/2', true], - ['/x/*/y/*', '/x/1/2', false], - // literal dot is a literal, not "any char" - ['/a.b', '/a.b', true], - ['/a.b', '/axb', false], - // a ':name.ext' param GREEDILY swallows the '.ext' into the single-segment - // param (matches dispatch — cf. '/blog/:slug.xml' → '^/blog/[^/]+$'); it is - // NOT a literal-suffix match, but it still must not cross '/'. - ['/files/:name.json', '/files/a.json', true], - ['/files/:name.json', '/files/anything', true], - ['/files/:name.json', '/files/a/b', false], - // literal - ['/about', '/about', true], - ['/about', '/about/x', false], - ]; - - it('matches dispatch segment-anchored semantics across the golden matrix', () => { - const wrong: Array = []; - for (const [pattern, path, expected] of cases) { - if (matchConfigPattern(pattern, path) !== expected) { - wrong.push( - `pattern=${JSON.stringify(pattern)} path=${JSON.stringify(path)} expected=${expected}`, - ); - } - } - expect(wrong).toEqual([]); - }); }); From ad6407557bfa43dbcc2bf4458f18f8ab99531ffb Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 12 Jul 2026 12:25:31 +0800 Subject: [PATCH 10/12] feat(isr): resolve object routing.revalidate per-path at the edge (3-state pages table + pathRevalidate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the documented per-path ISR feature for Pages-mode apps (codex P1 on void#272) with zero regression and no wrong-serve, superseding both the probe-baking (over-cached a per-path 0 opt-out) and the drop-baking (lost a documented feature) steps. An OBJECT `routing.revalidate` is a per-PATH rule set; a single dynamic page pattern can straddle several entries incl. a per-path 0 opt-out, so it cannot collapse to one build-time TTL. The concrete request path — which only the edge has — makes resolution exact. So: - collectPages emits a 3-STATE pages entry: `revalidate: N` (ISR on), `revalidate: 0` (EXPLICIT per-page opt-out, honored verbatim), or ABSENT (inherit-eligible). Per-page export and global-number config still bake (each maps 1:1 to a TTL); an object config leaves pages inherit-eligible. - The RAW object config ships as a new `manifest.pathRevalidate` field (object-only; a number is already baked into every page). Dispatch/prewarm resolve a matched inherit-eligible page against pathRevalidate using the actual path (platform side). Two invariants: the explicit-0 vs absent distinction keeps a per-page `export const revalidate = 0` winning over config (precedence 2 > 3); pathRevalidate is the RAW config, not the folded collectRevalidate map, so catch-all shadowing stays fixed. Backward compatible (old dispatch reads the 3-state table as before, ignores pathRevalidate). Co-Authored-By: Claude Opus 4.8 --- packages/void/src/cli/deploy.ts | 15 ++- packages/void/src/cli/package.ts | 6 ++ packages/void/src/cli/types.ts | 10 ++ packages/void/src/isr-config.ts | 96 ++++++++++--------- .../void/test/unit/assemble-manifest.test.ts | 44 +++++++++ packages/void/test/unit/isr-config.test.ts | 55 +++++++---- 6 files changed, 160 insertions(+), 66 deletions(-) diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index d938ca519..390853345 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -1054,11 +1054,18 @@ export function assembleVoidManifest( ): DeployManifest { return buildVoidManifest({ ...opts, - // Bake each page's effective ISR TTL from its own `export const revalidate` - // AND the app's `void.json` `routing.revalidate` config (the RAW value, not - // the merged `collectRevalidate` map) so the `pages` table is self-sufficient - // — dispatch never falls back to the flat map for a matched page. + // Bake each page's BUILD-time ISR TTL (3-state) from its own `export const + // revalidate` first, then a global-NUMBER `routing.revalidate`. A page under + // an OBJECT config bakes NO TTL (inherit-eligible) — see `collectPages`. pages: isFrameworkMode ? undefined : collectPages(pageScan, revalidateConfig), + // Ship the RAW per-path OBJECT config alongside so dispatch can resolve those + // inherit-eligible pages against the ACTUAL request path (honoring per-path + // `0` opt-outs a dynamic pattern straddles). Object form only: a number + // config is already baked into every `pages` entry, so it is never consulted. + pathRevalidate: + isFrameworkMode || revalidateConfig == null || typeof revalidateConfig !== 'object' + ? undefined + : revalidateConfig, }); } diff --git a/packages/void/src/cli/package.ts b/packages/void/src/cli/package.ts index efa091033..497f82989 100644 --- a/packages/void/src/cli/package.ts +++ b/packages/void/src/cli/package.ts @@ -493,6 +493,7 @@ export function buildVoidManifest(opts: { framework?: string; revalidate?: number | Record; pages?: Array<{ pattern: string; revalidate?: number }>; + pathRevalidate?: Record; vars?: Record; queues?: Array; prerender?: Array; @@ -513,6 +514,7 @@ export function buildVoidManifest(opts: { framework, revalidate, pages, + pathRevalidate, vars, queues, prerender, @@ -610,6 +612,10 @@ export function buildVoidManifest(opts: { manifest.pages = pages; } + if (pathRevalidate && Object.keys(pathRevalidate).length > 0) { + manifest.pathRevalidate = pathRevalidate; + } + if (prerender && prerender.length > 0) { manifest.prerender = prerender; } diff --git a/packages/void/src/cli/types.ts b/packages/void/src/cli/types.ts index 27a6d4695..ff12b094f 100644 --- a/packages/void/src/cli/types.ts +++ b/packages/void/src/cli/types.ts @@ -184,6 +184,16 @@ export type DeployManifest = { revalidateQueryAllowlist?: Record>; /** Ordered page-route table for route-aware ISR resolution (pages mode). */ pages?: Array<{ pattern: string; revalidate?: number }>; + /** + * RAW per-path `void.json` `routing.revalidate` OBJECT config (pages mode). + * Shipped ALONGSIDE the `pages` table so dispatch can resolve a matched + * inherit-eligible page (a `pages` entry with NO baked `revalidate`) against + * the ACTUAL request path — honoring per-path rules and `0` opt-outs that a + * dynamic page pattern straddles, which build-time baking cannot. Distinct + * from (and uncontaminated by) the folded `revalidate` map. Object form only: + * a global-number config is already baked into every `pages` entry. + */ + pathRevalidate?: Record; prerender?: Array; assetConfig?: AssetConfig; vars?: Record; diff --git a/packages/void/src/isr-config.ts b/packages/void/src/isr-config.ts index 198e5c8ba..a2f27b688 100644 --- a/packages/void/src/isr-config.ts +++ b/packages/void/src/isr-config.ts @@ -85,70 +85,70 @@ export function collectRevalidate( } /** - * Resolve a single page's effective ISR TTL at BUILD time so the emitted - * `pages` table is SELF-SUFFICIENT: dispatch's `resolvePageTtl` first-matches - * the table and, on a match, uses ONLY that page's baked TTL — it never - * consults the flat `revalidate` map for a matched page. Before this, a page - * with no per-page `export const revalidate` baked no TTL, so a Void Pages app - * that configured ISR through a global-number `void.json` `routing.revalidate` - * silently lost ISR once the `pages` table shipped (page ISR OFF site-wide). + * Resolve a single page's BUILD-time ISR TTL for the 3-STATE `pages` table. + * Returns: + * - a number `> 0` — ISR ON with that baked TTL; + * - `0` — an EXPLICIT opt-out (a per-page `export const revalidate = 0`, or a + * global-number config of 0). Dispatch honors it verbatim and does NOT fall + * back to the per-path config; + * - `undefined` — INHERIT-eligible: the page bakes no TTL, and dispatch + * resolves the RAW per-path `routing.revalidate` OBJECT config (shipped as + * `manifest.pathRevalidate`) against the ACTUAL request path at the edge. * - * Two sources, in order — SOURCE-SEPARATED, which is what preserves the - * plan-0077 catch-all shadowing fix (a no-export page must NOT inherit a - * SIBLING page's per-page TTL): + * Sources, in precedence order — SOURCE-SEPARATED, which preserves the plan-0077 + * catch-all shadowing fix (a no-export page must NOT inherit a SIBLING page's + * per-page TTL — sibling exports never reach this function): * 1. an explicit per-page `export const revalidate` is authoritative — a - * positive value turns ISR on; an explicit 0 opts the page OUT and is NOT - * overridden by the app-wide config below; + * positive value turns ISR on; an explicit `0` opts the page OUT and is NOT + * overridden by the app-wide config; * 2. else a global-NUMBER `void.json` `routing.revalidate` (applies to every - * page); absent -> 0. + * page: `> 0` on, `0` off); + * 3. else (an OBJECT config, or none) → `undefined` (inherit-eligible). * - * An OBJECT (per-path map) `routing.revalidate` is deliberately NOT baked. It is - * a per-PATH rule set, and a single page pattern can straddle several of its - * entries — INCLUDING an explicit `0` opt-out on one of them (e.g. page - * `/blog/:slug` under `{ '/blog/private': 0, '/blog/*': 3600 }`). Collapsing that - * to one per-page TTL via a single representative probe path cannot honor the - * `0` and would OVER-cache a path the user opted out of — a correctness - * violation, not a mere under-cache. So an object-configured pages-mode app does - * not drive page ISR through the table (matched pages resolve 0); per-page - * `export const revalidate` is the supported per-route mechanism. See plan 0077. + * Why an OBJECT config is resolved at the EDGE, not baked here: it is a per-PATH + * rule set, and a single dynamic page pattern can straddle several of its entries + * — INCLUDING a per-path `0` opt-out (e.g. page `/blog/:slug` under + * `{ '/blog/private': 0, '/blog/*': 3600 }`). Collapsing that to one build-time + * TTL cannot honor the `0` and would OVER-cache an opted-out path — a wrong-serve. + * Dispatch has the concrete request path, so it resolves each rule per-path + * correctly. Baking is only safe for the whole-site number config and per-page + * exports (each maps to exactly one TTL). See plan 0077. */ function resolvePageBuildTtl( page: PageDefinition, revalidateConfig: number | Record | undefined, -): number { - // 1. Explicit per-page export wins (0 = explicit opt-out, preserved). +): number | undefined { + // 1. Explicit per-page export wins (0 = explicit opt-out, preserved verbatim). if (page.revalidate != null) { return page.revalidate > 0 ? page.revalidate : 0; } - // 2. App-wide void.json `routing.revalidate` — global NUMBER only. An object - // (per-path) config is intentionally not collapsed to a per-page TTL: it - // cannot faithfully honor a per-path `0` opt-out (see the doc comment). + // 2. App-wide void.json `routing.revalidate` — global NUMBER only. if (typeof revalidateConfig === 'number') { return revalidateConfig > 0 ? revalidateConfig : 0; } - return 0; + // 3. Object config (or none) → inherit-eligible; dispatch resolves per-path. + return undefined; } /** - * Build the ordered page-route table shipped in the deploy manifest as - * `pages`. Emits EVERY page route (with or without a TTL) in `pageScan.pages` - * order — which is already specificity-sorted (static → dynamic → catch-all) - * and is the exact order the framework's own `matchRoute` (pages/manifest.ts) - * resolves in. Patterns are emitted RAW (`:slug`, `:path{.+}`, `*`); the edge - * matcher (`matchSegmentPattern`) handles `:name{.+}` natively, so — unlike the - * flat `revalidate` map — catch-alls are NOT collapsed to `*`. + * Build the ordered page-route table shipped in the deploy manifest as `pages`. + * Emits EVERY page route in `pageScan.pages` order — already specificity-sorted + * (static → dynamic → catch-all), the exact order the framework's own + * `matchRoute` (pages/manifest.ts) resolves in. Patterns are emitted RAW + * (`:slug`, `:path{.+}`, `*`); the edge matcher (`matchSegmentPattern`) handles + * `:name{.+}` natively, so — unlike the flat `revalidate` map — catch-alls are + * NOT collapsed to `*`. * - * Each page's `revalidate` is resolved at BUILD time (`resolvePageBuildTtl`) - * from its own `export const revalidate` first, then a global-NUMBER `void.json` - * `routing.revalidate` config (`revalidateConfig`, the RAW config value — NOT - * the merged `collectRevalidate` map; an OBJECT per-path config is not baked, - * see `resolvePageBuildTtl`). This makes the table self-sufficient: dispatch's - * `resolvePageTtl` never has to fall back to the flat map for a matched page (a - * matched page with no baked TTL resolves to 0), so a Void Pages app that - * configures ISR through a global-number `routing.revalidate` keeps its ISR. + * Each entry carries a 3-STATE `revalidate` (see `resolvePageBuildTtl`): + * - `revalidate: N` (N > 0) — ISR on with baked TTL N; + * - `revalidate: 0` — EXPLICIT opt-out (per-page `= 0`, or global-0 config); + * - `revalidate` ABSENT — inherit-eligible; dispatch resolves the RAW per-path + * OBJECT config (`manifest.pathRevalidate`) against the real request path. * - * Consumers (dispatch, deploy prewarm) first-match over this list to pick the - * winning route, then apply that route's `revalidate` (0/absent = not ISR). + * The explicit-0 vs absent distinction is load-bearing: it lets dispatch tell a + * deliberate opt-out (never cache) apart from "no baked TTL, ask the per-path + * config" (which may cache), so a per-page `export const revalidate = 0` still + * wins over any `routing.revalidate` rule for the same path. */ export function collectPages( pageScan: PageScanResult | null, @@ -159,6 +159,10 @@ export function collectPages( } return pageScan.pages.map((page) => { const ttl = resolvePageBuildTtl(page, revalidateConfig); - return ttl > 0 ? { pattern: page.pattern, revalidate: ttl } : { pattern: page.pattern }; + // `undefined` → inherit-eligible (omit `revalidate`); a number (0 or N) is + // baked verbatim so dispatch can distinguish an explicit opt-out from inherit. + return ttl === undefined + ? { pattern: page.pattern } + : { pattern: page.pattern, revalidate: ttl }; }); } diff --git a/packages/void/test/unit/assemble-manifest.test.ts b/packages/void/test/unit/assemble-manifest.test.ts index 1b16702e7..535514b29 100644 --- a/packages/void/test/unit/assemble-manifest.test.ts +++ b/packages/void/test/unit/assemble-manifest.test.ts @@ -76,4 +76,48 @@ describe('assembleVoidManifest', () => { const scan = makeScan([makePage('/isr', 60)]); expect(assembleVoidManifest(scan, true, { bindings: MINIMAL_BINDINGS }).pages).toBeUndefined(); }); + + it('ships the RAW object config as manifest.pathRevalidate; those pages stay inherit-eligible', () => { + // Object `routing.revalidate` is not baked — pages stay inherit-eligible + // (absent revalidate) and the RAW config ships so dispatch resolves it + // per-path against the real request path. + const scan = makeScan([makePage('/blog/:slug'), makePage('/about')]); + const manifest = assembleVoidManifest( + scan, + false, + { bindings: MINIMAL_BINDINGS }, + { + '/blog/private': 0, + '/blog/*': 3600, + }, + ); + expect(manifest.pages).toEqual([{ pattern: '/blog/:slug' }, { pattern: '/about' }]); + expect(manifest.pathRevalidate).toEqual({ '/blog/private': 0, '/blog/*': 3600 }); + }); + + it('omits manifest.pathRevalidate for a global-number config (baked into every page)', () => { + // A number config is fully baked into `pages`, so no inherit-eligible page + // exists and pathRevalidate would never be consulted — it is not shipped. + const scan = makeScan([makePage('/a'), makePage('/b')]); + const manifest = assembleVoidManifest(scan, false, { bindings: MINIMAL_BINDINGS }, 3600); + expect(manifest.pages).toEqual([ + { pattern: '/a', revalidate: 3600 }, + { pattern: '/b', revalidate: 3600 }, + ]); + expect(manifest.pathRevalidate).toBeUndefined(); + }); + + it('omits manifest.pathRevalidate in framework mode even with an object config', () => { + const scan = makeScan([makePage('/blog/:slug')]); + const manifest = assembleVoidManifest( + scan, + true, + { bindings: MINIMAL_BINDINGS }, + { + '/blog/*': 3600, + }, + ); + expect(manifest.pages).toBeUndefined(); + expect(manifest.pathRevalidate).toBeUndefined(); + }); }); diff --git a/packages/void/test/unit/isr-config.test.ts b/packages/void/test/unit/isr-config.test.ts index 1b44b911d..d71f35286 100644 --- a/packages/void/test/unit/isr-config.test.ts +++ b/packages/void/test/unit/isr-config.test.ts @@ -180,11 +180,32 @@ describe('collectPages', () => { expect(collectPages(scan, { '/p': 9999 })).toEqual([{ pattern: '/p', revalidate: 120 }]); }); - // An explicit `export const revalidate = 0` is an opt-OUT and must NOT be - // overridden by an app-wide config (preserves pre-#272 opt-out semantics). - it('explicit per-page revalidate=0 opts out and is not overridden by config', () => { + // An explicit `export const revalidate = 0` is an opt-OUT: it bakes the + // DISTINGUISHED marker `revalidate: 0` (NOT absent) so dispatch treats it as a + // deliberate opt-out — never overridden by an app-wide config, never a per-path + // inherit. The `0`-vs-absent distinction is load-bearing (see the 3-state test). + it('explicit per-page revalidate=0 bakes the distinguished opt-out marker (revalidate:0)', () => { const scan = makeScan([makePage('/p', 0)]); - expect(collectPages(scan, 3600)).toEqual([{ pattern: '/p' }]); + expect(collectPages(scan, 3600)).toEqual([{ pattern: '/p', revalidate: 0 }]); + // Even with an object rule for the same path, the explicit opt-out marker wins. + expect(collectPages(scan, { '/p': 9999 })).toEqual([{ pattern: '/p', revalidate: 0 }]); + }); + + // The 3-STATE encoding in one table: a baked number `N` (ISR on), an explicit + // `0` (opt-out marker), and an ABSENT revalidate (inherit — dispatch resolves + // the per-path config against the real request path). Dispatch relies on the + // `0`-vs-absent distinction to tell a deliberate opt-out from "ask the config". + it('emits all three states: baked N, explicit 0 opt-out, and absent (inherit)', () => { + const scan = makeScan([ + makePage('/on', 120), // per-page export > 0 → baked N + makePage('/off', 0), // per-page export 0 → explicit opt-out marker + makePage('/inherit'), // no export, object config → inherit (absent) + ]); + expect(collectPages(scan, { '/inherit': 60 })).toEqual([ + { pattern: '/on', revalidate: 120 }, + { pattern: '/off', revalidate: 0 }, + { pattern: '/inherit' }, + ]); }); // Test 2 — REGRESSION GUARD (the must-fix): a global-number config must turn @@ -198,26 +219,28 @@ describe('collectPages', () => { ]); }); - // Test 3 — an OBJECT (per-path) config is deliberately NOT baked into per-page - // TTLs. A page pattern can straddle several config entries (incl. a per-path - // `0` opt-out), so collapsing it to one per-page TTL cannot honor the opt-out - // and would over-cache. Object-configured pages therefore bake NO TTL (resolve - // 0 at the edge); per-page `export const revalidate` is the per-route lever. - it('an object config is NOT baked into per-page TTLs (per-path, cannot collapse)', () => { + // Test 3 — an OBJECT (per-path) config bakes NO per-page TTL: those pages are + // INHERIT-eligible (absent revalidate). A page can straddle several object + // entries (incl. a per-path `0`), so it can't collapse to one build-time TTL — + // the raw config ships as `manifest.pathRevalidate` and dispatch resolves it + // against the ACTUAL request path. Absent revalidate = "ask the edge per-path". + it('an object config bakes no per-page TTL (pages are inherit-eligible)', () => { const scan = makeScan([makePage('/blog/:slug'), makePage('/about')]); expect(collectPages(scan, { '/blog/*': 3600 })).toEqual([ - { pattern: '/blog/:slug' }, // object config not baked → no revalidate (0) + { pattern: '/blog/:slug' }, // inherit-eligible → dispatch resolves per-path { pattern: '/about' }, ]); }); - // A per-path `0` opt-out inside an object config is exactly why object configs - // are not baked: a naive probe-based collapse would miss the `/blog/private: 0` - // rule and over-cache. Nothing is baked, so the opt-out can never be violated. - it('does not bake — and so never violates — a per-path 0 opt-out in an object config', () => { + // A per-path `0` opt-out inside an object config is exactly why baking is unsafe: + // a build-time collapse would miss `/blog/private: 0` and over-cache it. Instead + // the page bakes no TTL (inherit) and dispatch resolves the per-path config + // against the real path, so `/blog/private` correctly resolves 0 at the edge and + // `/blog/other` resolves 3600 — a per-path fidelity build-time baking can't give. + it('leaves an object-config page inherit-eligible so a per-path 0 opt-out is honored at the edge', () => { const scan = makeScan([makePage('/blog/:slug')]); expect(collectPages(scan, { '/blog/private': 0, '/blog/*': 3600 })).toEqual([ - { pattern: '/blog/:slug' }, // no TTL baked; /blog/private stays opted out at the edge + { pattern: '/blog/:slug' }, // no baked TTL; edge honors /blog/private:0 per-path ]); }); From 6c963ddcc00654f60a9b587db124b07f4643cfab Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 12 Jul 2026 12:44:37 +0800 Subject: [PATCH 11/12] test(isr): prove a REAL CLI build emits the 3-state manifest.pathRevalidate contract Adversarial-review follow-up: the pathRevalidate producer was covered only by unit tests of assembleVoidManifest (hand-constructed inputs). Adds an object routing.revalidate ({ '/docs': 120, '/docs/ref': 0 }) to the named-layouts fixture and asserts, from a REAL runFullDeploy build, that the finalize manifest ships pathRevalidate verbatim AND that the inherit-eligible /docs and /docs/ref pages carry NO baked revalidate (while /isr keeps its per-page 60). This closes the 'synthetic tests pass but a real build might not emit the contract dispatch consumes' gap. RED-verified (forcing pathRevalidate:undefined fails it). The fixture change is deploy-time only (routing.revalidate is read at deploy, not baked into the build), so named-layouts.test.ts is unaffected. Co-Authored-By: Claude Opus 4.8 --- .../integration/full-deploy-manifest.test.ts | 20 +++++++++++++++++++ playground/named-layouts/void.json | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/packages/void/test/integration/full-deploy-manifest.test.ts b/packages/void/test/integration/full-deploy-manifest.test.ts index 21757cd79..5ad82df3d 100644 --- a/packages/void/test/integration/full-deploy-manifest.test.ts +++ b/packages/void/test/integration/full-deploy-manifest.test.ts @@ -250,6 +250,26 @@ describe('runFullDeploy emits manifest.pages (Void pages app, end-to-end)', () = // The /isr page (pages/isr.server.ts → `export const revalidate = 60`) must // survive into the real deploy manifest with its exact TTL. expect(pages).toContainEqual({ pattern: '/isr', revalidate: 60 }); + + // The object `routing.revalidate` in the fixture void.json is NOT baked into + // per-page TTLs — a REAL build must emit the 3-state table + the RAW object + // config as `manifest.pathRevalidate` (this is the producer half of the + // cross-repo contract dispatch consumes; a manually-constructed manifest + // would not prove the actual CLI emits it). `/docs` and `/docs/ref` have no + // per-page export, so they ship INHERIT-ELIGIBLE (no `revalidate` key) and + // dispatch resolves the per-path config against the real request path. + const manifest = captured.finalizeBody!.manifest as unknown as { + pathRevalidate?: Record; + }; + expect(manifest.pathRevalidate).toEqual({ '/docs': 120, '/docs/ref': 0 }); + expect(pages).toContainEqual({ pattern: '/docs' }); + expect(pages).toContainEqual({ pattern: '/docs/ref' }); + // Neither inherit-eligible page carries a baked `revalidate` (that would be + // the unsafe build-time collapse of a per-path config — incl. the `0` opt-out). + const docsEntry = pages!.find((p) => p.pattern === '/docs'); + const docsRefEntry = pages!.find((p) => p.pattern === '/docs/ref'); + expect(docsEntry).not.toHaveProperty('revalidate'); + expect(docsRefEntry).not.toHaveProperty('revalidate'); }); it('suppresses manifest.pages in framework mode (Class-A framework forced via detectFramework)', async () => { diff --git a/playground/named-layouts/void.json b/playground/named-layouts/void.json index 33a12dfec..777b9fcec 100644 --- a/playground/named-layouts/void.json +++ b/playground/named-layouts/void.json @@ -2,5 +2,11 @@ "$schema": "./node_modules/void/schema.json", "worker": { "compatibility_date": "2026-02-24" + }, + "routing": { + "revalidate": { + "/docs": 120, + "/docs/ref": 0 + } } } From d132293274a0f1da5d5b794910e08de4d0014155 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 12 Jul 2026 13:57:54 +0800 Subject: [PATCH 12/12] docs(isr): note collectPages route-unawareness (bounded known limitation, 0077) Adversarial-review follow-up (codex P1 on #272). Verified: a broad page catch-all TTL can shadow a non-/api routes/ handler at dispatch and ISR-cache its response. PRE-EXISTING (a global-number routing.revalidate already did this on origin/main with looser guards; codex's /api/user example is already blocked by isApiPath), bounded by shouldBypassIsr (no cross-user auth leak). Documented as a bounded known limitation in plan 0077 rather than fixed here: the route-exclusion fix (prepend routes/ patterns as revalidate:0) has method and pattern-overlap caveats and is route-arbitration work outside this route-aware-TTL branch. Inline pointer added at the emission site. Co-Authored-By: Claude Opus 4.8 --- packages/void/src/isr-config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/void/src/isr-config.ts b/packages/void/src/isr-config.ts index a2f27b688..5b501c850 100644 --- a/packages/void/src/isr-config.ts +++ b/packages/void/src/isr-config.ts @@ -150,6 +150,14 @@ function resolvePageBuildTtl( * config" (which may cache), so a per-page `export const revalidate = 0` still * wins over any `routing.revalidate` rule for the same path. */ +// Route-unaware by design: this table is built from `pageScan.pages` only, so a +// broad catch-all page with a TTL can shadow a non-`/api` `routes/` handler at +// dispatch and ISR-cache its response (the worker serves the route, but dispatch +// resolves the page catch-all's TTL). PRE-EXISTING (a global-number +// `routing.revalidate` already did this), bounded by dispatch `shouldBypassIsr` +// (no cross-user auth leak) and `isApiPath` (`/api/*` excluded). Documented in +// plan 0077 as a bounded known limitation; the eventual fix prepends `routes/` +// patterns here as `revalidate: 0` opt-outs (with method/overlap caveats). export function collectPages( pageScan: PageScanResult | null, revalidateConfig?: number | Record | undefined,