Skip to content

refactor(router): ShellCaptureState off the request context (S6) - #1250

Merged
ivogt merged 1 commit into
reorg/stackfrom
reorg/s6-shell-capture-state
Oct 23, 2026
Merged

ivogt merged 1 commit into
reorg/stackfrom
reorg/s6-shell-capture-state

Conversation

@ivogt

@ivogt ivogt commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

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 on RequestContext. 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 in PublicRequestContext's omit list. Plan rango-d195 step S6 (target rango-d194 section 3.1) moves the capture's own state into one object owned by rsc/shell-capture.ts. No consumer-visible change: no public API, header, shell record format or timing changes.

Before / after (internal shape)

Before:

// server/request-context.ts
_shellCaptureRun?: boolean;
_shellCaptureForVisitor?: boolean;
_shellCaptureLoaderRecords?: Map<string, Promise<unknown>>;
_shellCaptureUnrecordedLoaderPush?: true;
_shellCaptureLoaderLanes?: ReadonlyMap<string, "live" | "bake">;
_routeRecordWindow?: RouteRecordWindow;
_shellCaptureGuardTripped?: { surface: string; fix: string };
_shellCaptureGuardTrippedLoaderId?: string;

// readers
if (reqCtx?._shellCaptureRun) reqCtx._shellCaptureLoaderLanes = lanes;

After:

// server/request-context.ts
_shellCaptureState?: ShellCaptureState;

// rsc/shell-capture.ts (field docs moved here)
export interface ShellCaptureState {
  readonly run: true;
  forVisitor: boolean | undefined;
  loaderRecords: Map<string, Promise<unknown>>;
  unrecordedLoaderPush: true | undefined;
  loaderLanes: ReadonlyMap<string, "live" | "bake"> | undefined;
  guardTripped: { surface: string; fix: string } | undefined;
  guardTrippedLoaderId: string | undefined;
  routeRecordWindow: RouteRecordWindow | undefined;
}

// server/render-pass.ts, RENDER_PASS_FIELDS["shell-capture"]: every field present
pass._shellCaptureState = { run: true, forVisitor: undefined, loaderRecords: new Map(), ... };

// readers
const captureState = reqCtx?._shellCaptureState;
if (captureState?.run) captureState.loaderLanes = lanes;

Which fields moved, and which stay

The plan listed 14 fields. Each was checked on the base for a non-capture writer or reader:

Field Where Why
_shellCaptureRun moved: run set only by the shell-capture row
_shellCaptureForVisitor moved: forVisitor set only on the capture (attemptCapture)
_shellCaptureLoaderRecords moved: loaderRecords created by the shell-capture row; read by the capture's drain
_shellCaptureUnrecordedLoaderPush moved: unrecordedLoaderPush written by the capture's push funnel
_shellCaptureLoaderLanes moved: loaderLanes written by createMatchContextForFull only under a capture
_shellCaptureGuardTripped moved: guardTripped written by the capture guard only under a capture
_shellCaptureGuardTrippedLoaderId moved: guardTrippedLoaderId same
_routeRecordWindow moved: routeRecordWindow only reader is the capture (captureAndStoreShell, capShellWindow); the capture starts with none
_shellKey stays the foreground serve gate (rsc-rendering.ts shellServePlan) sets it; testing serveShellRequest reads it
_shellForcedMiss stays handler.ts sets it on the foreground; the shell gate reads it
_shellLoaderSeed stays set on the HIT tail and on a partial replay; a capture never reads it (loader-cache.ts servedPins)
_shellImplicitCache stays set on the capture, the HIT tail and a partial replay
_shellFragmentPayload stays the HIT tail and a partial replay arm it; the capture only shadows it with false
_resolvedCacheKeys stays the foreground's key() memo; the capture reads it through the prototype by design

The name

_shellCaptureState, not _shellCapture or shellCapture:

  • On this base there is no RenderPassState object: S5 kept every pass a RequestContext built with Object.create, where internal members are _-prefixed and omitted from PublicRequestContext. A bare shellCapture would read as public.
  • _shellCapture was the removed foreground descriptor ("a capture is wanted"). ppr-shell-resume.md records it as gone, and three tests (cookie-store.test.ts twice, 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.
  • It matches the type name, ShellCaptureState.

Code-change summary

  • src/rsc/shell-capture.ts: ShellCaptureState with the moved field docs; RouteRecordWindow moves here from request-context.ts (only the window uses it). Readers use _shellCaptureState?.x. The push funnel in deriveShellCaptureContext writes through captureState, 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 with Object.create (see Notes).
  • src/server/request-context.ts: the eight fields and RouteRecordWindow are removed and _shellCaptureState is added (also in the PublicRequestContext omit list). Net -106 lines.
  • Readers renamed: 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.ts noteRouteRecordWindow writes the capture's window and returns early outside a capture.
  • Test kit: makeShellCaptureState(fields) in src/__tests__/kit/request-context.ts returns the state the shell-capture row creates (through deriveRenderPass), with fields written over it. The captureAndStoreShell stubs in shell-capture.test.ts use it, so they carry the full shape a real capture has. Tests that mock server/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.
  • Docs: field names in feature-map.md, feature-file-map.md (kit row, the new test among the characterisation tests), execution-model.md and 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 await is added or removed.

S3 shape test: the rename map

src/server/__tests__/derived-context-shape.rsc-test.tsx changes only where it reads these fields. No assertion is removed or loosened:

Before After
own keys _routeRecordWindow, _shellCaptureLoaderRecords, _shellCaptureRun own key _shellCaptureState, whose own keys are pinned (SHELL_CAPTURE_STATE: all eight fields, present from creation)
capture._shellCaptureLoaderRecords capture._shellCaptureState?.loaderRecords
capture._shellCaptureRun (true) capture._shellCaptureState?.run (true)
capture._routeRecordWindow (undefined) capture._shellCaptureState?.routeRecordWindow (undefined)
#684 case: base _routeRecordWindow: { reads: [] } base _shellCaptureState: { routeRecordWindow: { reads: [] } }; the capture's own window is still undefined

The 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:all on 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 failed router/__tests__/dev-reload-handoff.test.ts with a 5 s timeout during a dynamic import of router.js, at load average 19. That file reads none of these fields. It passed 3/3 alone, and the full rerun above passed.
  • Shell-capture unit suites and every serveShellRequest rsc suite pass (the router rsc config above, plus cloudflare-basic ppr-shell.rsc-test.tsx and the others).
  • New 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 true on the own-key check). With it, the test passes.
  • context-fast-properties.test.ts: two new itFast cases (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.
  • Router e2e, one --grep alternation shell-cache|shell-capture-timeout|shell-push-ownership|ppr-include-bake|ppr-ssr-false-inline|semantic-matrix, checked with --list first (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.
  • cloudflare-basic e2e, --grep "ppr-shell|ppr-bake-lane-jsx|ppr-push-ownership", on the final commit:
    • dev: 79 passed.
    • production: 77 passed.
  • On the first revision, where the router and cloudflare-basic dev runs went at the same time, there were three failures:
    • shell-cache (dev) › large sync Suspense section bakes into the shell got MISS instead of HIT on its first attempt and passed on retry. It then passed 3/3 alone (--repeat-each=3 --retries=0).
    • Two cloudflare-basic ppr-shell dev readiness (#719) tests timed out in beforeAll (an isolated dev server did not start within 30 s). Rerun alone, both passed.
    • None of the three was run on the base, and none failed in the runs on the final commit.
  • CI lint scripts: check-e2e-bucketing, check-e2e-parity --strict, check-e2e-console-baseline, check-suspense-contract, check-no-external-store, 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.ts changes.

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

  • Lint: oxlint (--deny-warnings) and oxfmt over the whole repo; nothing found after formatting the changed files. No rule silenced.
  • Dead code: searched the repo (src, tests, e2e, tests/, examples, apps, tools, docs) for the eight old names; none is left. RouteRecordWindow moved, not duplicated. The new kit helper makeShellCaptureState has callers in 7 test files. Nothing kept unused.
  • Tests: touched unit and rsc suites, the full 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.
  • Code review: reviewed the diff for meaning changes and ran a separate correctness review of it.
    • Fixed: a background re-render derived from a capture would have shared the capture's state object; it now derives its own (Notes). A first version used a shallow copy, which read the capture's fields as they were when the pass was made rather than live; the derivation reads them live, as the prototype did. The captureAndStoreShell stubs now carry the full state shape. The new test is listed in feature-file-map.md.
    • Left: the eight _shellCaptureState?.run === true checks are not folded into isShellCaptureActive. That changes import graphs, and warm-request.ts takes no runtime imports, so it belongs in its own change. run is kept (Notes). forVisitor is still set by attemptCapture right after the derivation, not through the row's init: producer B shares deriveShellCaptureContext and never set it. Moving it would need a gate, which makes it more than a rename. The push-funnel closures read captureState, declared after them, the same way they read derivedCtx before. The types live in rsc/shell-capture.ts as the plan says, and every importer uses import type. resetShellCaptureStateForTests resets module state and keeps its name.
  • Docs: field names updated in 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 docs ppr-shell-resume.md, prerender-every-route.md, loader-container-bake.md, handle-push-ownership.md and shell-fast-path.md. No skill names these fields.

Notes

  • A background re-render derived from a capture. A stale route cache() record read during a capture starts rerenderAndCacheRoute with the capture's context as its base. Before, that pass read the capture's flags through the prototype, live, and wrote _shellCaptureGuardTripped / _routeRecordWindow as its own properties, out of the capture's sight. With one state object inherited through the prototype, those writes would reach the capture: a cookies() read in the refresh would refuse the capture. The background row derives such a pass's state with Object.create(captureState). Reads fall through to the capture live, field writes stay the pass's own, and the loaderRecords Map 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.
  • noteRouteRecordWindow now 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 to undefined, and captureAndStoreShell reads only the capture's window. The output does not change.
  • run stays a field even though it is always true when the state exists. Each reader is then a direct rename (_shellCaptureRun === true becomes _shellCaptureState?.run === true), and a foreground descriptor-shaped object without run still does not arm capture.
  • Producer B (prerender/build-shell-capture.ts) derives through the same deriveShellCaptureContext and needs no change.

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.
@ivogt
ivogt merged commit 2fa88da into reorg/stack Oct 23, 2026
19 checks passed
@ivogt
ivogt deleted the reorg/s6-shell-capture-state branch October 23, 2026 21:17
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.
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.

1 participant