Skip to content

fix(cache): tag ISR/TPR-seeded entries with the live-render tag set#2061

Open
Divkix wants to merge 4 commits into
cloudflare:mainfrom
Divkix:fix/issue-1984-isr-seeded-tags
Open

fix(cache): tag ISR/TPR-seeded entries with the live-render tag set#2061
Divkix wants to merge 4 commits into
cloudflare:mainfrom
Divkix:fix/issue-1984-isr-seeded-tags

Conversation

@Divkix

@Divkix Divkix commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Problem

Prerender-seeded entries for dynamic routes are tagged from the concrete pathname (e.g. _N_T_/posts/hello/page), while runtime re-renders tag from the bracketed route pattern (_N_T_/posts/[slug]/page). A typed revalidatePath('/posts/[slug]','layout') emits _N_T_/posts/[slug]/layout, which can never reach a seeded entry — so stale prerendered HTML/RSC survives content updates until natural regeneration. Concrete-path revalidatePath('/posts/hello') still works; the gap is the typed/segment-pattern invalidation.

Next.js derives layout/page tags from the bracketed page for both build-time prerender and runtime re-render (single getImplicitTags(page, pathname) path), so seeded and rendered entries share an identical tag set. vinext's seed/TPR path used the concrete-path builder while its live render used a routeSegments-aware builder — a vinext-only divergence.

Closes #1984.

Fix

Make seeded and TPR-uploaded entries carry the identical tag set the live render produces.

  • Build prerender (prerender.ts) now records each app route's routeSegments — the raw filesystem segments (incl. route groups (main) and brackets [slug]), the same field the live render reads — into vinext-prerender.json.
  • seed-cache builds tags via buildAppPageTags(pathname, [], routeSegments) instead of the concrete-path buildAppPageCacheTags. Falls back to the concrete builder when routeSegments is absent (legacy manifest / root route) — byte-identical for static routes. (field-absent, not empty-array, is the fallback trigger: [] is valid for /).
  • TPR has only Cloudflare-analytics URLs (no route pattern), and its local server is the Node prod server (popular pages are served as cache HITs with no tags on the response). So the prod server now emits the live-computed implicit tags as an internal-only x-vinext-implicit-tags response header — gated strictly to the TPR User-Agent, on both the cache-hit and fresh-render paths, and stripped from inbound requests. buildTprKVPairs reads it verbatim, falling back to the concrete builder when absent.

Behavior notes

  • The exact-path tag (_N_T_/posts/hello) is still emitted, so concrete revalidatePath('/posts/hello') (no type) keeps working.
  • revalidatePath(concrete, 'page'|'layout') for a dynamic route no longer matches seeded entries — aligning with Next.js, where dynamic-route page/layout revalidation requires the route pattern (/posts/[slug]), not a concrete URL.
  • Route groups, catch-all / optional catch-all, @slots, and i18n are correct by construction (same routeSegments source as the live render). Pages Router is untouched (router === "app" only).

Tests

  • tests/seed-cache.test.ts (+5): dynamic-route bracketed tags; typed revalidatePath('/posts/[slug]','layout') invalidation; concrete revalidatePath('/posts/hello') still invalidates; route-group (main) case; legacy-manifest concrete fallback.
  • tests/tpr-kv-keys.test.ts (+3): buildTprKVPairs uses the x-vinext-implicit-tags header verbatim; route-group round-trip; header-absent fallback.
  • tests/request-pipeline.test.ts: updated for the new internal header (also asserts a forged inbound value is stripped).

Full unit suite green except a pre-existing better-sqlite3 native-build issue and a ppr-fallback-shell timing flake (passes in isolation), both unrelated. Typecheck/format clean for all changed files.

@pkg-pr-new

pkg-pr-new Bot commented Jun 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2061
npm i https://pkg.pr.new/create-vinext-app@2061
npm i https://pkg.pr.new/vinext@2061

commit: 72a68f7

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the #1984 fix. The core mechanism is sound: persisting routeSegments into the prerender manifest and routing seed-cache through buildAppPageTags(pathname, [], routeSegments) correctly produces the bracketed pattern tags the live render emits, and the round-trip encoding for the TPR header (encodeCacheTag -> encodeURIComponent/comma-join -> decodeURIComponent -> encodeCacheTag) is lossless and comma-safe. Tag-order assertions in the new tests match the builder output. Nice work on the test coverage.

Three things worth addressing before merge, plus a couple of nits:

  1. (Moderate) Info disclosure: the x-vinext-implicit-tags gate is a spoofable User-Agent with no outbound strip. dispatchAppPage is the shared App Router dispatch used in production (incl. Cloudflare Workers via app-rsc-entry), and filterInternalHeaders only strips inbound request headers — there is no outbound strip for this response header. So a public client that sends User-Agent: vinext-tpr/1.0 to a deployed Worker will receive x-vinext-implicit-tags, leaking internal route topology (route-group names like (main), dynamic segment patterns, full layout hierarchy). This is information that didn't leak before. Consider gating on a per-build secret token header instead of a guessable UA, and/or stripping response-only internal headers on the public response path.

  2. (Minor) Root-route fallback re-introduces the exact divergence this PR removes. For / the manifest omits routeSegments (empty), so seed-cache takes the buildAppPageCacheTags('/', []) fallback, which yields ["/", "_N_T_/", "_N_T_/layout", "_N_T_/page"]. But the live render passes routeSegments: [] to buildAppPageTags('/', fetchTags, []), which additionally emits _N_T_/index. So the seeded root entry's tag set is not byte-identical to the live render's — the _N_T_/index tag is missing. revalidatePath('/') still works (both carry _N_T_/), so impact is narrow, but the comment's 'byte-identical for static routes' claim is inaccurate for the root route specifically.

  3. (Minor/edge) Fresh-render TPR header is computed before the stream drains. decorateInternalImplicitTags calls getCollectedFetchTags() synchronously when renderAppPageLifecycle returns, but the live ISR cache write computes getPageTags() inside the post-drain cachePromise continuation (see app-page-cache.ts). Any fetch tags collected while the HTML/RSC stream drains will be in the stored ISR entry but missing from the TPR header, so the 'EXACT set live ISR stores' claim doesn't hold on the fresh-render path for pages that collect tags during streaming. The HIT path is correct (no render runs). Likely rare for TPR (mostly HITs), but worth a comment acknowledging the gap or capturing tags post-drain.

Nits: the instrumentation.test.ts cleanup-retry change is unrelated to #1984 and would be cleaner as its own commit/PR (it's already a separate commit, so just noting the scope). Otherwise looks good.

// fetches run so getCollectedFetchTags() is empty, matching the seeded entry's
// own path/route tag set.
const decorateInternalImplicitTags = (response: Response): Response => {
if (options.request.headers.get("user-agent") !== VINEXT_TPR_USER_AGENT) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spoofable gate + no outbound strip. This UA check is the only thing preventing x-vinext-implicit-tags from reaching real clients, but User-Agent: vinext-tpr/1.0 is trivially forgeable, and filterInternalHeaders only strips inbound request headers — nothing strips this header from the outbound public response. Since dispatchAppPage serves production traffic (incl. Cloudflare Workers), a public request with this UA receives the page's full implicit-tag set, leaking internal route topology (route-group names, dynamic segment patterns, layout hierarchy). Prefer a per-build secret token over a guessable UA, and/or strip response-only internal headers on the public path.

}
const tags = buildAppPageTags(
options.cleanPathname,
getCollectedFetchTags(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Header is fixed before the stream drains. getCollectedFetchTags() here runs synchronously when the lifecycle returns, but the live ISR write computes getPageTags() inside the post-drain cachePromise continuation. Fetch/cacheTag() tags collected while the stream drains will land in the stored ISR entry but not in this header, so on the fresh-render path the header is not always the 'EXACT set live ISR stores' (the comment above). The HIT path is fine. Consider capturing tags after the body drains, or softening the comment to note the fresh-render gap.

// (non-crashing) degrade for dynamic ones.
const tags = route.routeSegments
? buildAppPageTags(cachePathname, [], route.routeSegments)
: buildAppPageCacheTags(cachePathname, []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fallback is not byte-identical for the root route. The comment says the concrete-path fallback is 'byte-identical for static routes', but / (which always hits this branch since its routeSegments are empty) diverges: the live render does buildAppPageTags('/', fetchTags, []), which emits _N_T_/index, whereas buildAppPageCacheTags('/', []) does not. So the seeded root entry is missing the _N_T_/index tag that the live render stores. Basic revalidatePath('/') still works (both carry _N_T_/), so impact is narrow, but the claim is inaccurate for the root route — either special-case / to use buildAppPageTags(cachePathname, [], []), or adjust the comment.

@ask-bonk

ask-bonk Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

All comments posted successfully. Here's a summary of my review of PR #2061.

Review Summary

The core fix is sound: persisting routeSegments into vinext-prerender.json and routing seed-cache through buildAppPageTags(pathname, [], routeSegments) correctly produces the bracketed pattern tags (_N_T_/posts/[slug]/page) that the live render emits, so typed revalidatePath('/posts/[slug]', 'layout') now reaches seeded entries. I verified:

  • The TPR header round-trip (encodeCacheTagencodeURIComponent+comma-join → decodeURIComponentencodeCacheTag) is lossless and comma-safe.
  • The new test tag-order assertions match the actual builder output.
  • Inbound forged-header stripping is correctly wired via VINEXT_INTERNAL_HEADERS.
  • The fresh-render and HIT paths both invoke the decorator.

Issues raised (1 review + 3 inline comments)

  1. Moderate — info disclosure: x-vinext-implicit-tags is gated only by a spoofable User-Agent: vinext-tpr/1.0, and filterInternalHeaders only strips inbound headers. Since dispatchAppPage serves production traffic, a public client spoofing that UA receives the header, leaking internal route topology. Suggested a secret token gate and/or outbound stripping.

  2. Minor — root-route divergence: For / (always empty routeSegments), the concrete-path fallback omits _N_T_/index that the live render emits, contradicting the "byte-identical for static routes" comment.

  3. Minor/edge — fresh-render timing: The header captures getCollectedFetchTags() before the stream drains, while live ISR writes capture it after, so streaming-collected fetch tags can be missing from the header on the fresh-render path.

  4. Nit: the instrumentation.test.ts flake fix is unrelated scope.

No blocking correctness bugs in the happy path; the security item (#1) is the one I'd most want addressed before merge.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second-pass review. The core fix is correct — I traced the seed/TPR tag derivation and confirm persisting routeSegments into the manifest and routing seed-cache through buildAppPageTags(pathname, [], routeSegments) produces the bracketed pattern tags (_N_T_/posts/[slug]/page) that the live render emits, so typed revalidatePath('/posts/[slug]', 'layout') now reaches seeded entries. The TPR header round-trip (encodeCacheTagencodeURIComponent/comma-join → decodeURIComponentencodeCacheTag) is lossless and comma-safe (commas encode to %2C, so the split(',') is safe; literal %2C double-encodes to %252C and survives).

I independently confirm the three issues @ask-bonk raised; rather than duplicate the inline anchors, summarizing with priority + a concrete direction for the blocker:

1. (Should fix before merge) Info disclosure via spoofable UA + no outbound strip. Verified: __dispatchAppPage in entries/app-rsc-entry.ts:676 is the production Worker request handler, not just the local TPR subprocess. decorateInternalImplicitTags (app-page-dispatch.ts:674) gates only on User-Agent: vinext-tpr/1.0, and filterInternalHeaders (request-pipeline.ts:632) only strips inbound request headers — it is never applied to outbound responses. So any public client that sends User-Agent: vinext-tpr/1.0 to a deployed Worker receives x-vinext-implicit-tags, leaking route-group names ((main)), dynamic segment patterns, and the full layout hierarchy. This is data that did not leak before this PR. The UA gate is also brittle in another way: a real browser/bot that happens to send that UA would get the header on every page. Recommended fix: gate on a per-build secret token header (generated at build, known only to the TPR client) rather than a guessable UA. The token approach also removes the need for an outbound strip, since no public client can produce a valid token. If you keep the UA gate, you additionally need to strip x-vinext-implicit-tags from the outbound public response path.

2. (Minor) Root-route fallback is not byte-identical, contradicting the comment. Verified by tracing both builders for /: buildAppPageCacheTags('/', [])['/', '_N_T_/', '_N_T_/layout', '_N_T_/page'], whereas the live render's buildAppPageTags('/', fetchTags, []) additionally emits _N_T_/index (implicit-tags.ts:63). Since / always has empty routeSegments it always takes the fallback branch (seed-cache.ts:140-142), so the seeded root entry is missing _N_T_/index. revalidatePath('/') still works (both carry _N_T_/), so impact is narrow — but the 'byte-identical for static routes' claim in the comment is inaccurate for the root specifically. Either special-case / to buildAppPageTags(cachePathname, [], []), or soften the comment.

3. (Minor/edge) Fresh-render TPR header is captured before the stream drains. Verified: decorateInternalImplicitTags calls getCollectedFetchTags() synchronously when renderAppPageLifecycle returns (app-page-dispatch.ts:680), but the live ISR write computes getPageTags() (→ getCollectedFetchTags()) lazily inside the post-drain cachePromise continuation (app-page-cache.ts:678 and :799). Fetch/cacheTag() tags collected while the HTML/RSC stream drains land in the stored ISR entry but not in the TPR header, so the 'EXACT set live ISR stores' claim does not hold on the fresh-render path for pages that collect tags during streaming. The HIT path is fine (no render). Likely rare for TPR (mostly HITs), but worth either capturing post-drain or softening the comment.

Nit: the instrumentation.test.ts maxRetries change is a reasonable flake fix but unrelated to #1984 scope (already a separate commit, just noting).

No correctness bugs in the happy path. Issue #1 is the one I'd want resolved before merge.

@ask-bonk

ask-bonk Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Review posted. Summary of my findings:

Review of PR #2061

The core fix is correct. I independently traced the tag-derivation paths and verified:

  • Persisting routeSegments into vinext-prerender.json + routing seed-cache through buildAppPageTags(pathname, [], routeSegments) produces the bracketed pattern tags (_N_T_/posts/[slug]/page) the live render emits, so typed revalidatePath('/posts/[slug]', 'layout') now reaches seeded entries.
  • The TPR header round-trip is lossless and comma-safe (commas → %2C, literal %2C%252C).
  • Inbound forged-header stripping is correctly wired.

I confirmed all three issues the prior reviewer raised hold up under tracing, and posted a corroborating review prioritizing them:

  1. Should-fix (security)x-vinext-implicit-tags is gated only by a spoofable User-Agent, and filterInternalHeaders only strips inbound headers. Since __dispatchAppPage (app-rsc-entry.ts:676) serves production Worker traffic, any public client sending User-Agent: vinext-tpr/1.0 receives the header, leaking route topology. Recommended a per-build secret token gate (which also obviates outbound stripping).

  2. Minor — Root-route fallback omits _N_T_/index that the live render emits, contradicting the "byte-identical for static routes" comment.

  3. Minor/edge — Fresh-render path captures getCollectedFetchTags() before the stream drains; live ISR captures it after, so streaming-collected fetch tags can be missing from the header.

Plus a nit on the unrelated instrumentation.test.ts flake fix. No correctness bugs in the happy path; issue #1 is the blocker.

github run

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 72a68f7 against base 8d0a18d using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 126.6 KB 126.7 KB ⚫ +0.1%
Client entry size (gzip) vinext 120.6 KB 120.7 KB ⚫ +0.1%
Dev server cold start vinext 2.74 s 2.74 s ⚫ -0.0%
Production build time vinext 3.00 s 2.97 s ⚫ -1.0%
RSC entry closure size (gzip) vinext 98.6 KB 98.7 KB ⚫ +0.1%
Server bundle size (gzip) vinext 165.3 KB 165.4 KB ⚫ +0.1%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

Divkix added a commit to Divkix/vinext that referenced this pull request Jun 24, 2026
- Security: replace spoofable User-Agent gate for x-vinext-implicit-tags
  with a per-build random UUID token (VINEXT_TPR_SECRET / x-vinext-tpr-secret).
  Token is generated in prerenderRoutes, passed to the subprocess via env,
  sent as a request header, and stripped from inbound requests via
  VINEXT_INTERNAL_HEADERS. Deployed Workers never have the env var set so the
  header can never be emitted to public clients.

- Root route: prerender.ts now always writes routeSegments (even []) into the
  manifest so seed-cache uses buildAppPageTags for root, emitting the
  _N_T_/index tag that the concrete-path fallback missed.

- Fresh-render comment: clarify that streaming-collected fetch tags may be
  missing from the TPR header on the fresh-render path (rare; mostly HITs).

Tests: +1 root-route seed test (_N_T_/index), updated request-pipeline
assertion for VINEXT_INTERNAL_HEADERS to include new secret header.
@Divkix

Divkix commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all three items:

  1. (Security): Replaced the spoofable User-Agent gate with a per-build random UUID token. prerenderRoutes now calls randomUUID(), passes the value to the local prod-server subprocess via VINEXT_TPR_SECRET env, and sends it as x-vinext-tpr-secret in every pre-render request. The server checks process.env.VINEXT_TPR_SECRET and the header must match exactly — the env var is never set in a deployed Worker so the response header can never reach a public client. The secret header is also added to VINEXT_INTERNAL_HEADERS so it gets stripped from inbound requests.
  2. (Root route): prerender.ts now always writes routeSegments (even []) into the manifest — the length > 0 guard was the bug. With routeSegments: [] present, seed-cache takes the buildAppPageTags branch for root and correctly emits N_T/index. Added a focused regression test.
  3. (Fresh-render timing): Updated the comment to acknowledge the gap (streaming-collected fetch tags may be missing from the header on the fresh-render path). Left the behavior as-is — TPR is mostly HITs and the path/layout/page implicit tags are always present; fixing it properly would require teeing the stream and seems out of scope.

Divkix added 3 commits July 12, 2026 22:50
Prerender-seeded entries for dynamic routes were tagged from the concrete
pathname (e.g. `_N_T_/posts/hello/page`), while runtime re-renders tag from
the bracketed route pattern (`_N_T_/posts/[slug]/page`). A typed
`revalidatePath('/posts/[slug]','layout')` emits the bracketed tag, so it
could never reach seeded entries — stale prerendered HTML/RSC survived
content updates until natural regeneration. Next.js derives layout/page tags
from the bracketed page for both build-prerender and live-render, so this was
a vinext-only divergence.

Make seeded and TPR-uploaded entries carry the IDENTICAL tag set the live
render produces:

- Build prerender now records each app route's `routeSegments` (the raw
  filesystem segments, including route groups and brackets — the same field
  the live render uses) in `vinext-prerender.json`.
- seed-cache builds tags via `buildAppPageTags(pathname, [], routeSegments)`
  instead of the concrete-path `buildAppPageCacheTags`, falling back to the
  concrete builder when `routeSegments` is absent (legacy manifest / root
  route) — byte-identical for static routes.
- TPR has only analytics URLs with no route pattern, so the prod server now
  emits the live-computed implicit tags as an internal-only
  `x-vinext-implicit-tags` response header (gated strictly to the TPR
  User-Agent, on both cache-hit and fresh-render responses, and stripped from
  inbound requests). `buildTprKVPairs` reads it verbatim, falling back to the
  concrete builder when absent.

The exact-path tag (`_N_T_/posts/hello`) is still emitted, so concrete
`revalidatePath('/posts/hello')` keeps working. `revalidatePath(concrete,'page')`
for a dynamic route no longer matches seeded entries — aligning with Next.js,
where dynamic-route page/layout revalidation requires the route pattern.

Closes cloudflare#1984
`withInjectClientServer` spins up a real Vite dev server; its esbuild/rollup
workers and file watchers can still be flushing files into the temp dir while
the recursive `fs.rmSync` walks it, racing to ENOTEMPTY even with `force:true`.
Add `maxRetries`/`retryDelay` so teardown retries on ENOTEMPTY/EBUSY instead of
failing the unit shard. Unrelated to the cloudflare#1984 cache change; surfaced as a flaky
CI failure on this branch.
- Security: replace spoofable User-Agent gate for x-vinext-implicit-tags
  with a per-build random UUID token (VINEXT_TPR_SECRET / x-vinext-tpr-secret).
  Token is generated in prerenderRoutes, passed to the subprocess via env,
  sent as a request header, and stripped from inbound requests via
  VINEXT_INTERNAL_HEADERS. Deployed Workers never have the env var set so the
  header can never be emitted to public clients.

- Root route: prerender.ts now always writes routeSegments (even []) into the
  manifest so seed-cache uses buildAppPageTags for root, emitting the
  _N_T_/index tag that the concrete-path fallback missed.

- Fresh-render comment: clarify that streaming-collected fetch tags may be
  missing from the TPR header on the fresh-render path (rare; mostly HITs).

Tests: +1 root-route seed test (_N_T_/index), updated request-pipeline
assertion for VINEXT_INTERNAL_HEADERS to include new secret header.
@Divkix Divkix force-pushed the fix/issue-1984-isr-seeded-tags branch from b53d196 to 7743f62 Compare July 13, 2026 03:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ISR: prerender-seeded cache tags use concrete paths, so typed revalidatePath('/posts/[slug]') can't reach seeded entries

2 participants