Repository navigation
fix(router): PPR capture retry-in-place, quiet expected aborts, DSL middleware() attachment - #644
Merged
Merged
Conversation
…iddleware() attachment The just-merged PPR shell cache had three cold-start DX defects: a loading()-equipped route needed ~5 MISS requests before HIT, each dumping a full DOMException AbortError stack; and the once-per-key warning blamed a missing loading() boundary even when the real cause was cold modules. - Retry-in-place: runShellCapture now runs attemptCapture up to twice with a 400ms delay; a null/aborted first attempt (cold modules) usually heals on the retry in the same background task, so cold-start reaches HIT in 1-2 requests. - Quiet expected aborts: pass prerender an onError that swallows the deliberate capture abort (React's default logged one DOMException per pending hole). One concise "[ShellCache] capture attempt N/2 ..." breadcrumb per attempt under the middleware debug flag (threaded via the _shellCapture descriptor). - Warning rewrite: names BOTH causes (cold-start self-heals vs missing loading()) with the distinguishing signal, fires only after the retry, and uses a shipped-safe doc pointer (skill path + GitHub URL, per a05c825). - Refused-capture backoff: an ineligible route is negatively cached with an EXPONENTIAL window (1s..60s) after the retry also fails, so app-wide mounting never re-renders a doomed route every request; a cold-but-eligible route recovers on the next request or two. - DSL middleware() attachment: verified route middleware only runs in the RSC handler's executeRender (not router.match), so it never re-enters the capture; added a _shellCaptureRun bypass guard as a hard invariant. New DSL-attached e2e subtrees in test-app and cloudflare-basic. - tags option on ShellCacheOptions (string[] | (ctx)=>string[]) unioned with the auto-collected shell tags. - Action-correctness e2e (test-app): JS action on hole data, JS action on cached+tagged shell material + updateTag, no-JS PE POST, DSL POST bypass. - Nav links for the PPR routes in both apps.
commit: |
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
The just-merged PPR shell cache (#641) had three cold-start DX defects observed in a live dev session — all cosmetic (every route eventually HIT), but they made a healthy route look broken:
/shell-cacheroute withloading()(a perfectly capturable shape) needed ~5 MISS requests before it flipped to HIT, and every one of those requests dumped a fullDOMException [AbortError]stack to the console.no usable shellwarning fired for that route and blamed "a loader route without a route-level loading() boundary" — but the route hadloading(). The real cause was cold modules, and the message sent the user hunting for a route-shape bug that didn't exist.Root causes (both found by instrumenting a live dev server, not guessed):
prerenderdefaultonError. Capture works by aborting (controller.abort()freezes the prelude and postpones the live holes);captureShellHTMLpassed noonError, so React logged the abort reason once per pending hole, on every capture that still had a live hole at abort time — i.e. every cold capture.Before vs. after (the user's log transcript)
Cold
GET /shell-cache(aloading()-equipped route), server output:Before — ~5 MISS requests, two DOMException dumps per cold capture:
After — MISS then HIT in 1-2 requests, clean log (retry healed in the same background task, so no breadcrumb was even needed here):
For a route that genuinely can't capture (
/shell-cache/no-hole, noloading()), the retry breadcrumbs and the rewritten warning replace the stack dumps:Consumer-facing changes
1. Attach the shell middleware via the
urls()middleware()DSLRoute middleware wraps the render pass with a
Response-returningnext()— exactly the onion the shell middleware needs. It now works attached per-subtree, not only viarouter.use():MISS -> HITand HIT composition are byte-identical to the global attachment (proven by new dev+prod e2e in both apps). Actions bypass it for free (a POST is non-GET), and the background capture never re-enters it.2.
tagsoption for operational shell invalidationUnioned with the tags the capture auto-collects (every
cacheTag(...)from shell material). Invalidating any tag a shell carries drops that shell -> MISS -> recapture.Semantics table
reportCacheErroronceprerenderabort reason (signal.aborted && AbortError)onError— no stack dumpdeps.onErrorupdateTag(tag)x-rango-shellabsent/MISS on the POST_shellCaptureRun)next(), no getShell, no flags armedCode-change summary
src/ssr/index.tsx—createShellCaptureHandlerthreadsdeps.onError;prerendergets anonErrorthat swallows the deliberate abort and reports genuine errors. This is the fix for the DOMException noise — it lives at theprerendercall because that's the exact source (React's defaultonError).src/rsc/shell-capture.ts—runShellCaptureis a two-attempt loop (attemptCapturex2, 400ms delay);captureAndStoreShellreturns a discriminated outcome (stored | no-shell, or throws a genuine error) instead of warning itself; concise debug breadcrumbs gated on the descriptor'sdebug; rewritten once-per-key warning (both causes + shipped-safe pointer); exponential refused-capture backoff inscheduleShellCapture; option-tags union collected-tags inattemptCapture.src/cache/shell-cache.ts—tagsoption;debug+tagsthreaded onto the_shellCapturedescriptor;_shellCaptureRunbypass guard at the top of the handler (scar tissue for DSL attachment).src/server/request-context.ts—debugfield on the_shellCapturedescriptor type.docs/design/ppr-shell-resume.md(retry-in-place + exponential backoff);skills/ppr/SKILL.md(Attaching viamiddleware(), Invalidation: tags vsrevalidate(), cold-start pitfall,tagsoption).Deliverable 4(a) verification (definitive)
Route middleware attached via
urls()middleware()does NOT re-run inside the background capture.router.match()only collects route middleware into theMatchResult(src/router/match-result.ts:258); it is executed by the RSC handler'sexecuteRender()(src/rsc/handler.ts:1198-1221), which the background capture bypasses entirely (runShellCapturecallsctx.router.match()directly, thenbuildFullPayload+ Flight render — neverexecuteMiddleware). The_shellCaptureRunbypass guard makes inertness inside any capture render a hard invariant regardless of attachment or future changes.Test plan
captureAndStoreShelloutcome contract; exponential backoff (window + expiry + success-clears); option-tags union collected-tags intoputShell; middleware_shellCaptureRunbypass (no getShell, no flags); descriptordebug/tagsthreading.pnpm run test:unit:allgreen (router 4791 + all consumer dogfood suites).shell-cachesuite (regression gate — the retry touches every capture path) 10 dev / 9 prod; newshell-cache-dsl4/4 both; newshell-cache-action(hole mutation, shell+updateTag, DSL POST bypass, no-JS PE) 4 dev / 5 prod.ppr-shell13 dev / 12 prod incl. the new DSL describe.check:e2e-bucketingOK./shell-cache-> MISS->HIT at GET2, no DOMException dumps, no warning;/shell-cache/no-hole-> concise retry breadcrumbs + rewritten warning + shipped-safe pointer, no dumps.Notes / call-outs
docs/string was theno usable shellwarning; it now points at the/pprskill (ships in the tarball) plus the absolute GitHub URL for the design doc. JSDoc/code comments keep repo-relative paths (read in-repo). A sweep ofsrc/confirmed no other runtime strings carried an unshippeddocs/path.warmToHit's multi-request recovery — re-breaking the very cold-start DX this PR fixes. Escalating from 1s lets eligible routes recover immediately while a truly doomed route ramps to the 60s cap. Production (pre-built modules -> capture on attempt 1) never trips it.<form action={serverAction}>drifts in a frozen shell (React encodes server-action form fields differently between the capture and serve render). The 10(c) PE fixture therefore lives on its own/shell-cache-action/peroute exercised only no-JS (nothing hydrates), documented inline; the JS-action routes keep their forms out of the frozen shell.