Repository navigation
fix(router): PPR gate honors a token-set per-request nonce (#656) - #657
Merged
Merged
Conversation
commit: |
ivogt
force-pushed
the
fix/ppr-nonce-token-gate
branch
from
July 4, 2026 09:12
997e3fd to
7b0c1f2
Compare
ivogt
added a commit
that referenced
this pull request
Jul 4, 2026
…p, fix snapshot-test flake (#652 item 3) Three coordinated changes unblocking cloudflare-basic-e2e on cold CI runners (failures on the main run for 2586ea9, the PR #657 runs, and PR #659's own first CI run): 1. Dev backoff cap (runtime). The refused-capture backoff window is exponential per consecutive failure, `min(BASE * 2^(failures-1), ceiling)`; the ceiling was a flat 60s in every mode. On a cold runner each cold re-probe climbs the count, and once the window exceeds ~16s it outlasts warmToHit's 20s poll: every request is skipped as backed-off, an eternal MISS. The ceiling is now mode-dependent: 60s in production (a genuinely ineligible route should be re-probed rarely), ~2s in dev, where the dominant no-shell cause is a cold module graph that warms on the very attempt that failed. Dev signal is `process.env.NODE_ENV !== "production"`, matching handler.ts / server-action.ts / progressive-enhancement.ts. 2. PPR dev warmup (test-side, the sufficient fix). PR #659's first CI run showed the cap alone is not sufficient: one probe cycle is ~12s (5s attempt + 5s in-place retry + 2s capped backoff), so the 20s window fits ~2 cycles and today's cold runners need more because the dev module graph is compiled ON DEMAND during the capture render, repeatedly blowing the 5s budget (physics hole failed 3x at ~20s). Extend tests/cloudflare-basic's existing dev-warmup setup project (which the dev project already depends on) with a ppr warmup: GET every ppr fixture route under an isolated `?probe=warmup` shell key (module-graph warm only, no test-visible entry pre-stored), then poll each capturable fixture to x-rango-shell HIT, proving the full capture pipeline (Flight render, fizz prerender+abort, putShell) is hot before any real test's warm window starts. Cap + warmup together: the warmup makes first attempts succeed; the cap remains the safety net for anything cold the warmup missed. warmToHit windows and runtime constants are otherwise untouched. 3. shell-snapshot.test.ts 1ms flake (also blocking CI). "serves snapshot values AS FRESH" builds the seeded store and the expected value from two separate snapshotOf() calls, and segData embeds `Date.now() + 60_000` as expiresAt — a millisecond tick between the calls fails the deep-equal (seen on the #659 run). Pin the clock with fake timers (the shell-cache.test.ts pattern). The rest of the file was audited: no other assertion deep-equals across two Date.now() instants. Production backoff ceiling/growth, retry-in-place, the in-flight set, and the e2e warm-window lengths are unchanged. #652 items 1-2 (barrier narrowing, drain unification) remain open.
ivogt
added a commit
that referenced
this pull request
Jul 4, 2026
…dget, snapshot-test flake (#652 item 3) Four coordinated changes unblocking cloudflare-basic-e2e on cold CI runners (failures on the main run for 2586ea9, the PR #657 runs, and PR #659's own first CI run): 1. Dev backoff cap (runtime). The refused-capture backoff window is exponential per consecutive failure, `min(BASE * 2^(failures-1), ceiling)`; the ceiling was a flat 60s in every mode. On a cold runner each cold re-probe climbs the count, and once the window exceeds ~16s it outlasts warmToHit's 20s poll: every request is skipped as backed-off, an eternal MISS. The ceiling is now mode-dependent: 60s in production (a genuinely ineligible route should be re-probed rarely), ~2s in dev, where the dominant no-shell cause is a cold module graph that warms on the very attempt that failed. Dev signal is `process.env.NODE_ENV !== "production"`, matching handler.ts / server-action.ts / progressive-enhancement.ts. 2. PPR dev warmup (test-side, the sufficient fix). PR #659's first CI run showed the cap alone is not sufficient: one probe cycle is ~12s (5s attempt + 5s in-place retry + 2s capped backoff), so the 20s window fits ~2 cycles and today's cold runners need more because the dev module graph is compiled ON DEMAND during the capture render, repeatedly blowing the 5s budget (physics hole failed 3x at ~20s). Extend tests/cloudflare-basic's existing dev-warmup setup project (which the dev project already depends on) with a ppr warmup: GET every ppr fixture route under an isolated `?probe=warmup` shell key (module-graph warm only, no test-visible entry pre-stored), then poll each capturable fixture to x-rango-shell HIT, proving the full capture pipeline (Flight render, fizz prerender+abort, putShell) is hot before any real test's warm window starts. Cap + warmup together: the warmup makes first attempts succeed; the cap remains the safety net for anything cold the warmup missed. warmToHit windows and runtime constants are otherwise untouched. 3. CI budget (the 600s owner). cloudflare-basic's playwright globalTimeout was `10 * 60 * 1000` on CI; the merged PPR stack grew the sequential dev -> production -> hmr run past 10m, so healthy runs brushed the cap and any flake retry tipped it over — Playwright killed the run mid-flight ("Timed out waiting 600s for the test suite to run"), failing whichever test was executing (the 0ms hmr-warmup skip-cascade, the mid-hmr kill at 10:31:06 while hmr tests were passing). Raised to 14m, and the cloudflare-basic-e2e job's timeout-minutes from 15 to 20 so the workflow cap contains ~3-4m setup + the 14m playwright budget. vite-rsc-demo and rangojs-router keep their 600s globalTimeout: their jobs shard/split, and recent CI runs show per-job playwright time of ~3-5m — nowhere near the cap. 4. shell-snapshot.test.ts 1ms flake (also blocking CI). "serves snapshot values AS FRESH" builds the seeded store and the expected value from two separate snapshotOf() calls, and segData embeds `Date.now() + 60_000` as expiresAt — a millisecond tick between the calls fails the deep-equal (seen on the #659 run). Pin the clock with fake timers (the shell-cache.test.ts pattern). The rest of the file was audited: no other assertion deep-equals across two Date.now() instants. Production backoff ceiling/growth, retry-in-place, the in-flight set, and the e2e warm-window lengths are unchanged. #652 items 1-2 (barrier narrowing, drain unification) remain open.
ivogt
added a commit
that referenced
this pull request
Jul 4, 2026
…dget, snapshot-test flake (#652 item 3) (#659) Four coordinated changes unblocking cloudflare-basic-e2e on cold CI runners (failures on the main run for 2586ea9, the PR #657 runs, and PR #659's own first CI run): 1. Dev backoff cap (runtime). The refused-capture backoff window is exponential per consecutive failure, `min(BASE * 2^(failures-1), ceiling)`; the ceiling was a flat 60s in every mode. On a cold runner each cold re-probe climbs the count, and once the window exceeds ~16s it outlasts warmToHit's 20s poll: every request is skipped as backed-off, an eternal MISS. The ceiling is now mode-dependent: 60s in production (a genuinely ineligible route should be re-probed rarely), ~2s in dev, where the dominant no-shell cause is a cold module graph that warms on the very attempt that failed. Dev signal is `process.env.NODE_ENV !== "production"`, matching handler.ts / server-action.ts / progressive-enhancement.ts. 2. PPR dev warmup (test-side, the sufficient fix). PR #659's first CI run showed the cap alone is not sufficient: one probe cycle is ~12s (5s attempt + 5s in-place retry + 2s capped backoff), so the 20s window fits ~2 cycles and today's cold runners need more because the dev module graph is compiled ON DEMAND during the capture render, repeatedly blowing the 5s budget (physics hole failed 3x at ~20s). Extend tests/cloudflare-basic's existing dev-warmup setup project (which the dev project already depends on) with a ppr warmup: GET every ppr fixture route under an isolated `?probe=warmup` shell key (module-graph warm only, no test-visible entry pre-stored), then poll each capturable fixture to x-rango-shell HIT, proving the full capture pipeline (Flight render, fizz prerender+abort, putShell) is hot before any real test's warm window starts. Cap + warmup together: the warmup makes first attempts succeed; the cap remains the safety net for anything cold the warmup missed. warmToHit windows and runtime constants are otherwise untouched. 3. CI budget (the 600s owner). cloudflare-basic's playwright globalTimeout was `10 * 60 * 1000` on CI; the merged PPR stack grew the sequential dev -> production -> hmr run past 10m, so healthy runs brushed the cap and any flake retry tipped it over — Playwright killed the run mid-flight ("Timed out waiting 600s for the test suite to run"), failing whichever test was executing (the 0ms hmr-warmup skip-cascade, the mid-hmr kill at 10:31:06 while hmr tests were passing). Raised to 14m, and the cloudflare-basic-e2e job's timeout-minutes from 15 to 20 so the workflow cap contains ~3-4m setup + the 14m playwright budget. vite-rsc-demo and rangojs-router keep their 600s globalTimeout: their jobs shard/split, and recent CI runs show per-job playwright time of ~3-5m — nowhere near the cap. 4. shell-snapshot.test.ts 1ms flake (also blocking CI). "serves snapshot values AS FRESH" builds the seeded store and the expected value from two separate snapshotOf() calls, and segData embeds `Date.now() + 60_000` as expiresAt — a millisecond tick between the calls fails the deep-equal (seen on the #659 run). Pin the clock with fake timers (the shell-cache.test.ts pattern). The rest of the file was audited: no other assertion deep-equals across two Date.now() instants. Production backoff ceiling/growth, retry-in-place, the in-flight set, and the e2e warm-window lengths are unchanged. #652 items 1-2 (barrier narrowing, drain unification) remain open.
The PPR axis-1 safety gate only saw a nonce produced by the
createRouter({ nonce }) provider (threaded as the `nonce` param). A per-request
nonce set via the `nonce` ContextVar token in middleware (ctx.set(nonce, value))
left the threaded param undefined, so a ppr route sailed through the gate,
entered capture, and baked the capture request's nonce into the SHARED shell;
on a HIT that frozen nonce was served to every visitor and the browser's CSP
rejected the nonced scripts for all but the capture request.
The gate now reads the token off the post-middleware request variables at the
commit point (nonce ?? contextGet(reqCtx._variables, nonceToken)) — sound
because the commit point runs after the whole middleware chain. A declared-but-
gated route logs a once-per-key warning (warnPprNonceActiveOnce, mirroring
warnShellStoreMissingOnce) and serves pure axis 1 with no x-rango-shell header;
non-ppr routes stay silent and zero-cost. Capture internals are untouched — the
single gate guards both serve and capture scheduling.
Tests: unit gate matrix (token-set blocks + no getShell/no schedule, provider
blocks, warn-once per key naming the route, non-ppr+token = no warn); e2e
test-app shell-cache + cloudflare-basic ppr-shell (dev+prod) — a new ppr route
whose route-DSL middleware() sets crypto.randomUUID() via the token stays off
HIT across repeated GETs with a DISTINCT nonce each. Docs: ppr SKILL, design
scar-tissue note, feature-map/feature-file-map.
ivogt
force-pushed
the
fix/ppr-nonce-token-gate
branch
from
July 4, 2026 10:50
7b0c1f2 to
622fda0
Compare
Merged
6 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
PPR's axis-1 safety gate for a per-request CSP nonce only accounted for a nonce produced by the
createRouter({ nonce })provider. A per-request nonce supplied via thenonceContextVar token —ctx.set(nonce, value)in middleware — was invisible to the gate: the threadednonceparam stayedundefined, so apprroute still entered capture and baked the capture request's nonce into the SHARED shell. On a warm HIT that one frozen nonce was served to every visitor, and the browser's CSP rejected the nonced inline scripts/styles for all but the capture request.This contradicted the documented guarantee (
skills/ppr→ "What always stays on axis 1": per-request CSP nonce). The guarantee held for provider-supplied nonces only.Before vs. after (consumer code)
An app that sets its CSP nonce via the token instead of the provider:
pprroute behaviorx-rango-shell: MISS, bakes one nonce into the shared shellx-rango-shellheaderSemantics table
Behavior at the PPR commit point, per nonce source × route:
pprroutepprroutecreateRouter({ nonce }))ctx.set(nonce, …)in middleware)A nonce-gated
pprroute emits nox-rango-shellheader (mirrors the missing-store diagnostic) — it never participates in PPR, so it can never flip to HIT.Code change
src/rsc/rsc-rendering.ts— the gate. Removednonce === undefinedfrom the OUTER PPR entry condition and moved the nonce check INSIDE, afterresolvePprConfig(so only DECLARED routes pay any cost). The effective nonce isnonce ?? contextGet(reqCtx._variables, nonceToken)— the threaded provider param first (cheap short-circuit), then the token off the post-middleware request variables. This is only sound because the commit point runs AFTER the whole middleware chain, so a middleware-set token is already present.isPartial/GET/prerender-collect/RSC checks stay in the outer condition; capture internals untouched (the single gate guards serve AND capture scheduling).src/rsc/shell-serve.ts— newwarnPprNonceActiveOnce(key), a once-per-key diagnostic mirroringwarnShellStoreMissingOnce.contextGet(reqCtx._variables, nonceToken)— the idiomatic ContextVar read offRequestContext._variables(same primitiveresponse-route-handler.tsuses forctx.get), withnonce as nonceTokenfromrsc/nonce.js.Test plan
src/rsc/__tests__/rsc-rendering-shell-ppr.test.ts): token-set nonce blocks (nogetShell, no capture scheduled, no header) + warns once/key; provider nonce blocks + warns once/key (existing behavior pinned + now-warns); non-pprroute with token nonce = pure axis 1, no warning. 18 passed.e2e/shell-cache.test.ts+ fixture/shell-cache/nonce-token): route-DSLmiddleware()setscrypto.randomUUID()via the token; repeated HTML GETs never HIT and carry a DISTINCT nonce each (per-request freshness; also pins the commit-point ordering). Dev + production.e2e/ppr-shell.test.ts+/ppr-nonceinsrc/urls.tsx): same shape. Dev + production. Regenerated + committedrouter.named-routes.gen.tsfor both apps.shell-cache(30) andppr-shell(30) suites green in both modes (no regression from the gate restructure); mini capture-path suite green.pnpm run typecheck,pnpm run test:unit:all,pnpm run lint,pnpm run format,pnpm check:e2e-bucketing.The warn-once is a worker-side
console.warn(not observable through the Playwright request harness), so the e2e pins MISS-never-HIT + distinct-nonce and the warn is pinned by the unit test — the established precedent for the missing-store warning.Notes
nonceContextVar token (onlycreateRouter({ nonce })gates it) → shared shell bakes a frozen nonce #656 suggested fix 3):skills/ppr/SKILL.mdgains a "The proper way to supply a nonce" section and thenoncetoken's docstring (src/rsc/nonce.ts) now states the contract —createRouter({ nonce })is the canonical path (threaded into NonceContext/useNonce, Scripts/MetaTags, Flight payload scripts, AND sets the token); a directctx.set(nonce, value)in middleware is app-managed only: it is readable viactx.get(nonce)and gates PPR, but the router's own SSR machinery resolves its nonce BEFORE middleware and will not apply a token-set value to its scripts.skills/ppr/SKILL.md("What always stays on axis 1" now genuinely covers provider AND token + warn-once),docs/design/ppr-shell-resume.md(scar-tissue note + pipeline diagram + serve-plumbing),feature-map.md/feature-file-map.md.x-rango-shell: MISS; the correct contract (and what ships) is no header, matching the missing-store doctrine the brief also asked to mirror and the pre-existing provider-nonce unit test. Flagged here for reviewer awareness.resolvePprConfigon nonce'd requests too (previously skipped) — a cheap property read only for DECLARED-pprroutes.Fixes #656