Repository navigation
refactor(router): ShellCaptureState off the request context (S6) - #1250
Merged
Merged
Conversation
The eight request-context fields only a PPR shell capture writes or reads (_shellCaptureRun, _shellCaptureForVisitor, _shellCaptureLoaderRecords, _shellCaptureUnrecordedLoaderPush, _shellCaptureLoaderLanes, _shellCaptureGuardTripped, _shellCaptureGuardTrippedLoaderId, _routeRecordWindow) become one ShellCaptureState object, owned by rsc/shell-capture.ts and reached as ctx._shellCaptureState. The shell-capture row of RENDER_PASS_FIELDS creates it with every field, so it keeps one shape while the capture writes it. Readers use _shellCaptureState?.run and so on; field docs move with the fields. _shellKey, _shellForcedMiss, _shellLoaderSeed, _shellImplicitCache, _shellFragmentPayload and _resolvedCacheKeys stay on the request context: the foreground, a PPR HIT tail or a partial replay writes or reads each. A background re-render a capture starts (a stale route cache() record the capture read) derives its state from the capture's with Object.create: it reads the capture's values live and its field writes stay its own, as the per-field properties did through the pass's prototype. noteRouteRecordWindow notes only on a capture, the window's one reader. No await is added or removed.
49 of 50 tasks
ivogt
added a commit
that referenced
this pull request
Oct 23, 2026
Brings in S10b (#1254), S6 (#1250), S9 (#1244) and S14b (#1252). Conflicts: docs/internal/feature-file-map.md and feature-map.md. Both keep reorg/stack's text plus this branch's two facts: the cache row's CFCacheStore sentence (readL1, readKv, the family descriptors, the read bench) and the "Cloudflare store family reads" bullet, placed after the Vercel store bullets.
13 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.
devcorner: rango-t136 · session rango-s297
Problem
A PPR shell capture keeps what it records about its own render (the capture switch, bake-lane loader records, loader lanes, guard trips, the route-record window, ...) as separate
_-fields onRequestContext. Every one of them is meaningful only on a capture's derived context, yet each is declared on the request context, added as an own property one by one while the capture renders, and listed again inPublicRequestContext's omit list. Plan rango-d195 step S6 (target rango-d194 section 3.1) moves the capture's own state into one object owned byrsc/shell-capture.ts. No consumer-visible change: no public API, header, shell record format or timing changes.Before / after (internal shape)
Before:
After:
Which fields moved, and which stay
The plan listed 14 fields. Each was checked on the base for a non-capture writer or reader:
_shellCaptureRunrun_shellCaptureForVisitorforVisitor_shellCaptureLoaderRecordsloaderRecords_shellCaptureUnrecordedLoaderPushunrecordedLoaderPush_shellCaptureLoaderLanesloaderLanes_shellCaptureGuardTrippedguardTripped_shellCaptureGuardTrippedLoaderIdguardTrippedLoaderId_routeRecordWindowrouteRecordWindow_shellKey_shellForcedMiss_shellLoaderSeed_shellImplicitCache_shellFragmentPayloadfalse_resolvedCacheKeysThe name
_shellCaptureState, not_shellCaptureorshellCapture:RenderPassStateobject: S5 kept every pass aRequestContextbuilt withObject.create, where internal members are_-prefixed and omitted fromPublicRequestContext. A bareshellCapturewould read as public._shellCapturewas the removed foreground descriptor ("a capture is wanted").ppr-shell-resume.mdrecords it as gone, and three tests (cookie-store.test.tstwice,loader-mask-capture.test.ts) still set it as the case that must not arm capture. Reusing the name would turn those into tests of the new state.ShellCaptureState.Code-change summary
src/rsc/shell-capture.ts:ShellCaptureStatewith the moved field docs;RouteRecordWindowmoves here from request-context.ts (only the window uses it). Readers use_shellCaptureState?.x. The push funnel in deriveShellCaptureContext writes throughcaptureState, taken once after the derivation.src/server/render-pass.ts: the shell-capture row creates the state with every field present, which replaces its three own fields (_shellCaptureRun,_shellCaptureLoaderRecords,_routeRecordWindow). For a capture base, the background row derives the pass's state from the capture's withObject.create(see Notes).src/server/request-context.ts: the eight fields andRouteRecordWindoware removed and_shellCaptureStateis added (also in thePublicRequestContextomit list). Net -106 lines.server/context.ts(tripShellCaptureGuard),server/rewrite-trail.ts,rsc/full-payload.ts,prerender/warm-request.ts,router/match-api.ts,router/match-handlers.ts,router/segment-resolution/loader-mask.ts,router/segment-resolution/loader-cache.ts,router/match-middleware/cache-store.ts.src/cache/cache-scope.tsnoteRouteRecordWindow writes the capture's window and returns early outside a capture.makeShellCaptureState(fields)insrc/__tests__/kit/request-context.tsreturns the state the shell-capture row creates (throughderiveRenderPass), withfieldswritten over it. The captureAndStoreShell stubs inshell-capture.test.tsuse it, so they carry the full shape a real capture has. Tests that mockserver/request-context.js(loader-mask-capture, loader-pins, loader-seed-overlay, await-before-flush) cannot load the kit there. They keep plain literals with the fields they set before.feature-map.md,feature-file-map.md(kit row, the new test among the characterisation tests),execution-model.mdand five design docs.Source (non-test): 13 files, +220 / -178; whole change 44 files, +646 / -402. The plan's -250/+150 assumed all 14 fields would move and their docs would shrink. Here six fields stay and the docs move verbatim. No
awaitis added or removed.S3 shape test: the rename map
src/server/__tests__/derived-context-shape.rsc-test.tsxchanges only where it reads these fields. No assertion is removed or loosened:_routeRecordWindow,_shellCaptureLoaderRecords,_shellCaptureRun_shellCaptureState, whose own keys are pinned (SHELL_CAPTURE_STATE: all eight fields, present from creation)capture._shellCaptureLoaderRecordscapture._shellCaptureState?.loaderRecordscapture._shellCaptureRun(true)capture._shellCaptureState?.run(true)capture._routeRecordWindow(undefined)capture._shellCaptureState?.routeRecordWindow(undefined)_routeRecordWindow: { reads: [] }_shellCaptureState: { routeRecordWindow: { reads: [] } }; the capture's own window is stillundefinedThe HIT-tail, background and rebase cases are unchanged: none of their own keys moved.
Test plan
pnpm run typecheck: 16/16 tasks.pnpm run test:unit:allon the final commit: router unit 566 files, 9353 passed / 5 skipped / 6 todo; router rsc 84 files, 1067 passed / 3 expected fail; cloudflare-basic 18 + 13 files, 90 + 39 passed; vite-rsc-demo 6 + 1 files; e2e/mini 8 + 2 files; shared-e2e 4 files. All green. One earlier run of the same tree failedrouter/__tests__/dev-reload-handoff.test.tswith a 5 s timeout during a dynamic import ofrouter.js, at load average 19. That file reads none of these fields. It passed 3/3 alone, and the full rerun above passed.serveShellRequestrsc suite pass (the router rsc config above, plus cloudflare-basicppr-shell.rsc-test.tsxand the others).src/server/__tests__/render-pass-capture-state.test.ts(3 tests): a capture owns a fresh state and the request has none; a background re-render of the request has none; a background re-render a capture starts reads the capture's state live (a field the capture sets after the pass exists reads through), and its guard trip stays its own. With the background row's derivation removed, the third test fails (expected false to be trueon the own-key check). With it, the test passes.context-fast-properties.test.ts: two newitFastcases (they run on this Node, not skipped). The state keeps V8 fast properties through every capture write, and a background re-render derived from a capture keeps them too. The existing cases stay green.--grepalternationshell-cache|shell-capture-timeout|shell-push-ownership|ppr-include-bake|ppr-ssr-false-inline|semantic-matrix, checked with--listfirst (7 files: semantic-matrix, shell-cache, shell-cache-action, shell-capture-timeout, shell-push-ownership, and the ppr-include-bake and ppr-ssr-false-inline scenarios). On the final commit, one run at a time:--project=dev --no-deps: 132 passed.--project=production --no-deps: 135 passed.--grep "ppr-shell|ppr-bake-lane-jsx|ppr-push-ownership", on the final commit:shell-cache (dev) › large sync Suspense section bakes into the shellgot MISS instead of HIT on its first attempt and passed on retry. It then passed 3/3 alone (--repeat-each=3 --retries=0).ppr-shell dev readiness (#719)tests timed out inbeforeAll(an isolated dev server did not start within 30 s). Rerun alone, both passed.node --test tools/*.test.mjs, check-doc-links, check-docs-api, check-skills, generate-skills-catalog --check, generate-route-types --check, check-internals (request-context-object-create 18, unchanged). All OK.oxlint --deny-warnings: 0 warnings, 0 errors.oxfmt --check .: clean. No*.gen.tschanges.Timing: to be measured in a quiet window; the results will be added here. The S5 bench
src/server/__tests__/render-pass.bench.tsx("MISS + shell capture", "HIT + PPR tail") is unchanged and runs as it is.Practices
--deny-warnings) and oxfmt over the whole repo; nothing found after formatting the changed files. No rule silenced.RouteRecordWindowmoved, not duplicated. The new kit helpermakeShellCaptureStatehas callers in 7 test files. Nothing kept unused.test:unit:all, and the targeted e2e above in dev and production. One new test file pins which pass has the state and whose writes reach it. Two new fast-properties cases pin the state's shape. The S3 shape test is changed only by the rename map above.feature-file-map.md._shellCaptureState?.run === truechecks are not folded intoisShellCaptureActive. That changes import graphs, andwarm-request.tstakes no runtime imports, so it belongs in its own change.runis kept (Notes).forVisitoris still set by attemptCapture right after the derivation, not through the row's init: producer B sharesderiveShellCaptureContextand never set it. Moving it would need a gate, which makes it more than a rename. The push-funnel closures readcaptureState, declared after them, the same way they readderivedCtxbefore. The types live inrsc/shell-capture.tsas the plan says, and every importer usesimport type.resetShellCaptureStateForTestsresets module state and keeps its name.docs/internal/feature-map.md(also a sentence on the state under "Derived render passes"),docs/internal/feature-file-map.md(kit row),docs/internal/execution-model.md, and the design docsppr-shell-resume.md,prerender-every-route.md,loader-container-bake.md,handle-push-ownership.mdandshell-fast-path.md. No skill names these fields.Notes
rerenderAndCacheRoutewith the capture's context as its base. Before, that pass read the capture's flags through the prototype, live, and wrote_shellCaptureGuardTripped/_routeRecordWindowas its own properties, out of the capture's sight. With one state object inherited through the prototype, those writes would reach the capture: acookies()read in the refresh would refuse the capture. The background row derives such a pass's state withObject.create(captureState). Reads fall through to the capture live, field writes stay the pass's own, and theloaderRecordsMap is the capture's, exactly as before. Whether that pass should render as a capture at all is recorded on the ticket (rango-t136-c2) as an open question, next to rango-t160.noteRouteRecordWindownow returns early outside a capture. Before, the foreground, a HIT tail and a background re-render each computed a window that nothing read: the capture starts with its own reset toundefined, and captureAndStoreShell reads only the capture's window. The output does not change.runstays a field even though it is alwaystruewhen the state exists. Each reader is then a direct rename (_shellCaptureRun === truebecomes_shellCaptureState?.run === true), and a foreground descriptor-shaped object withoutrunstill does not arm capture.deriveShellCaptureContextand needs no change.