Skip to content

refactor(router): deriveRenderPass, one declared field table for derived renders (S5) - #1245

Merged
ivogt merged 2 commits into
reorg/stackfrom
reorg/s5-render-pass
Oct 23, 2026
Merged

ivogt merged 2 commits into
reorg/stackfrom
reorg/s5-render-pass

Conversation

@ivogt

@ivogt ivogt commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

devcorner: rango-t135 · session rango-s275

Problem

A second render over a request (a PPR shell capture, a PPR HIT tail, a background re-render of a stale route record) runs on Object.create of the request's context, so every field it does not own reads the visitor request's value through the prototype. Each of the three sites built its derived context by hand, with its own reset list: deriveShellCaptureContext (an Object.assign plus about 18 own-property writes), createTailContext in serveShellHit (barrier wiring, then three seed writes after it) and rerenderAndCacheRoute (an Object.assign of 13 fields plus bindRes). A field missing from one list leaks foreground state into the derived render without any error: #684 was a capture that inherited the foreground's already-resolved render barrier and baked foreground handle data into the shared shell. With three lists in three files, nothing shows side by side what each pass owns.

This is S5 of the internals reorganisation (plan rango-d195, target architecture rango-d194 section 3.1). No consumer-visible behaviour changes: the public ctx.* surface, the RequestContext field declarations and rebaseRequestContext are untouched.

Before / after (internal shape)

Before, three sites:

// rsc/shell-capture.ts
const derivedCtx = Object.assign(Object.create(reqCtx), { _handleStore: freshHandleStore, _routeRecordWindow: undefined });
if (identity) { derivedCtx.request = identity.request; /* ... */ }
wireRenderBarrier(derivedCtx, freshHandleStore);
derivedCtx._requestTags = new Set();
// ... 14 more own-property writes, then the recording store when reqCtx._cacheStore is set

// rsc/rsc-rendering.ts
const tailCtx = Object.create(reqCtx);
wireRenderBarrier(tailCtx, handleStore);
// ... wrap _resolveRenderBarrier; later _shellLoaderSeed, _shellImplicitCache, _shellFragmentPayload

// router/match-middleware/background-revalidation.ts
const renderCtx = Object.assign(Object.create(requestCtx), { _handleStore: handleStore, /* 12 more */ });
bindRes(renderCtx, () => sink.res);

After, one table and one constructor in server/render-pass.ts:

export const RENDER_PASS_FIELDS = {
  "shell-capture":           { renderBarrier: "own",       own(pass, init, base) { /* every own field, each with its reason */ } },
  "ppr-tail":                { renderBarrier: "own",       own(pass, init) { /* ... */ } },
  "background-revalidation": { renderBarrier: "inherited", own(pass, init) { /* ... */ } },
};

// the three sites
const derivedCtx = deriveRenderPass(reqCtx, "shell-capture", { handleStore: freshHandleStore, identity, recording });
const seededCtx  = deriveRenderPass(reqCtx, "ppr-tail", { handleStore, loaderSeed, implicitCache });
const renderCtx  = deriveRenderPass(requestCtx, "background-revalidation", { handleStore, sink });

Each row reproduces exactly the own-field set its site had. That includes the background pass inheriting the request's render barrier, which is kept as it is and recorded as question rango-t135-q1.

Code changes

  • packages/rangojs-router/src/server/render-pass.ts (new): RenderPassInit (what each caller builds), RenderPurpose (its keys), RENDER_PASS_FIELDS (one row per purpose; the reset-rationale comments from the three sites moved onto the rows) and deriveRenderPass(base, purpose, init). The constructor runs Object.create(base), then wireRenderBarrier when the row owns its barrier, then the row.

  • rsc/shell-capture.ts deriveShellCaptureContext: builds the recording store and implicit doc scope (when the request has a cache store) and calls deriveRenderPass. The handle-store push funnel and _resolvedCacheKeys ??= new Map() on the request stay at the site. The attemptCapture JSDoc's per-field bullets moved onto the table row.

  • rsc/rsc-rendering.ts serveShellHit: createTailContext is gone. The tail is derived after its loader seed is built, with the seed and the docTail implicit scope passed in init. Nothing reads the derived context in between, and no derivation step has a side effect on the request.

  • router/match-middleware/background-revalidation.ts rerenderAndCacheRoute: calls deriveRenderPass with its handle store and the throwaway sink. The second createRequestContext used as the sink stays until S7.

  • server/request-context.ts: the wireRenderBarrier and _handleStore JSDoc name deriveRenderPass.

  • tools/internals-baseline.json: request-context-object-create is lowered by 3 with node tools/check-internals.mjs --update. background-revalidation.ts, rsc-rendering.ts and shell-capture.ts go from 1 to 0, and nothing is raised. In non-test source, Object.create of a RequestContext now appears only in server/render-pass.ts.

  • server/__tests__/render-pass.bench.tsx (new, vitest bench, vitest.rsc.config.ts) times:

    • the capture context, the navigation-only capture context and the background re-render;
    • a MISS (foreground render plus capture) and a HIT (prelude plus PPR tail) through serveShellRequest, over a fixed route set.

    The bench reaches the passes only through deriveShellCaptureContext, rerenderAndCacheRoute and serveShellRequest, so the same file runs on the base commit.

  • Docs:

    • docs/internal/feature-map.md: Request Context section.
    • docs/internal/feature-file-map.md: Request context row.
    • docs/internal/rendered-barrier.md: who wires the capture's barrier.

Diff: 11 files, +614 / -229.

  • Source outside tests: +362 / -216.
  • render-pass.ts is 300 lines: 167 are comments (126 moved off the three sites, the rest the module, row and type docs) and 124 are code.
  • Tests and bench: +246 / -7.

Test plan

  • server/__tests__/derived-context-shape.rsc-test.tsx (S3 characterisation, unchanged): 7/7 green. A mutated table turns 6 of its 6 derivation cases red: the capture barrier inherited, and _tracing, the tail's _shellFragmentPayload and the background _setKeepCacheDirective dropped.
  • server/__tests__/context-fast-properties.test.ts, new derived render pass cases: each purpose keeps V8 fast properties.
    • Red on the Object.assign cut (capture case: expected false to be true). Green on the named-store rows.
    • A before/after probe of five derived contexts was fast on both base and head: the capture with a store, without one, and with identity; the tail at match start; the background pass.
  • Unit:
    • Shell and cache suites, 101 files: 1618 passed, 5 skipped. They are router/__tests__/shell-capture-barrier-isolation.test.ts, rendered-barrier.test.ts, background-revalidation.test.ts, rsc/__tests__/, prerender/__tests__/build-shell-capture.test.ts, server/__tests__/context-fast-properties.test.ts and cache/__tests__/.
    • The serveShellRequest rsc suites (testing/__tests__/serve-shell-request*.rsc-test.tsx) plus the S3 test: 240 passed, 3 expected fail.
  • pnpm run test:unit:all (every package) on the final commit:
    • router: 564 files, 9348 passed, 5 skipped, 6 todo;
    • router rsc: 83 files, 1065 passed, 3 expected fail;
    • mini, vite-rsc-demo, cloudflare-basic and shared-e2e unit and rsc suites: all green.
  • E2e in packages/rangojs-router with --grep "shell-cache|prerender-ppr|semantic-matrix". That covers shell-cache, shell-cache-action, prerender-ppr and semantic-matrix; --list first showed 125 dev and 126 production tests in 4 files.
    • Dev: 125/125 passed. A first dev run failed 3 tests and timed out several beforeAll hooks at load average 22-27. An edit to render-pass.ts during that run broke the dev server's full reload ("./server.ts" is not exported). The rerun, with no edits, passed 125/125.
    • Production: 126/126 passed.
  • E2e in tests/cloudflare-basic, e2e/ppr-shell.test.ts e2e/cache.test.ts:
    • Production: 73/73 passed.
    • Dev: 65/66. The one failure was ppr-shell dev readiness: injected boot-race (#719) > injected reopt NOT-READY: the FIRST request re-polls to HIT (expected HIT, got MISS). Rerun alone on this branch, it passed 3/3. It was not run on the base commit, so it is not called pre-existing.
  • The e2e runs above were on the first commit of this branch. Its review amend changed runtime code only by moving the background pass's bindRes(pass, () => sink.res) into its row, with the same call in the same order. The other changes are type-only edits, comments, the test move and the bench. Unit, rsc and typecheck were rerun on the amended commit.
  • Checks:
    • pnpm run typecheck: 16 tasks;
    • oxlint --deny-warnings: 0 warnings;
    • oxfmt --check .;
    • node --test tools/*.test.mjs: 51 passed;
    • check-e2e-bucketing, check-e2e-parity --strict, check-e2e-console-baseline, check-suspense-contract, check-no-external-store, check-doc-links, check-docs-api, check-skills, generate-skills-catalog --check, generate-route-types --check and check-internals: all pass.
    • No *.gen.ts change.
  • Hot-path timing (rango-d195 section 7): not measured here. The machine was loaded (load average about 25). See Notes.

Practices

  • Lint: oxlint --deny-warnings and oxfmt --check . are clean on the change. Nothing was silenced.
  • Dead code:
    • Removed: createTailContext; the wireRenderBarrier and bindRes imports the three sites no longer use; a one-row res knob on the table policy, now a bindRes call in the background row; and a separate fast-properties test file, folded into context-fast-properties.test.ts.
    • Kept on purpose: RENDER_PASS_FIELDS, RenderPurpose and RenderPassInit are exported with no importer outside render-pass.ts. The plan names the table as the module's export, and the two types are deriveRenderPass's signature.
  • Tests:
    • Ran the touched unit, rsc and e2e suites and the full unit gate (see Test plan).
    • Added the fast-property cases for the new module, shown red before green.
    • derived-context-shape.rsc-test.tsx pins the behaviour and was not changed.
  • Code review: the diff was reviewed for correctness, reuse, simplification, efficiency and depth. No correctness regression was found.
    • Fixed:
      • the duplicated V8 natives probe (cases moved into context-fast-properties.test.ts);
      • a test header that narrated the change;
      • the bench's own request-context factory, replaced by the internal kit's makeRequestContext, which is on base too;
      • RenderPurpose now derives from RenderPassInit's keys;
      • PassUnderConstruction narrowed to the one readonly field the rows write;
      • PassFieldPolicy is no longer exported;
      • the res knob noted above.
    • Left, with reasons:
      • The background pass inherits the request's barrier. That is the owner's call for this step, recorded as rango-t135-q1.
      • The ppr-tail init.handleStore is the request's own store (handler.ts:1099) and is kept exactly as the site passed it. Tying it to base._handleStore is a later cleanup.
      • The renderBarrier field stays in the table, so the Follow-ups: caching/PPR/render-pipeline audit (DRY consolidations, deferred coverage, held/investigate items) #684 field is stated per row.
      • The two rows' common fields (_handleStore, _metricsStore, _renderErrors, _recoveredHandlerErrors, _onResponseCallbacks) stay written out in each row. A shared helper would hide each row's set, and S6 and S7 shrink both rows.
      • The recording store is still built at the capture site, which keeps cache/ runtime imports out of server/.
      • The S3 test's stale createTailContext name: that test must stay unchanged.
  • Docs:
    • Updated docs/internal/feature-map.md, docs/internal/feature-file-map.md and docs/internal/rendered-barrier.md, and the request-context.ts JSDoc.
    • AGENTS.md already names deriveRenderPass as the landed seam.

Notes

  • The S3 characterisation test server/__tests__/derived-context-shape.rsc-test.tsx is unchanged. Its PPR tail describe still says createTailContext, the closure this PR removes. The test is left as it is because the proof bar for this step is "unchanged and green".

  • Background pass barrier: kept inherited, as today, and recorded as question rango-t135-q1. The answer there is to keep it in S5 and look at it separately, with a userland proof first: does a refresh that reads the visitor's barrier ever wait on or release the wrong render?

  • Size: after the comment trim, render-pass.ts is 195 lines (62 of them comments). The step is a move; its net source line change is small, and most of the new file is the table and its types.

  • Timing, measured on a quiet machine (load average under 4, no other test runs), render-pass.bench.tsx, 6 rounds alternating base (7b38ae4) and head. Each cell is the median of the per-round medians, with the range of round medians in brackets:

    bench base head head vs base
    shell capture context 0.75 us [0.75-0.79] 0.75 us [0.75-0.79] -0.0%
    shell capture context, navigation-only 1.04 us [0.96-1.08] 1.00 us [0.96-1.04] -4.0%
    background re-render (stub resolution) 4.17 us [4.04-4.29] 4.04 us [3.83-4.12] -3.0%
    MISS + shell capture 30.69 ms [27.55-30.78] 30.40 ms [28.52-30.44] -0.9%
    HIT + PPR tail 6.69 ms [5.40-6.79] 6.66 ms [5.50-6.73] -0.3%

    No regression.

After review rango-t135-c1

ivogt added 2 commits October 23, 2026 18:40
A PPR shell capture, a PPR HIT tail and a background re-render of a stale
route record each render on Object.create of the request's context. Each
site kept its own hand-built reset list. server/render-pass.ts now holds
RENDER_PASS_FIELDS, one row per purpose listing every field the pass owns
and why, and deriveRenderPass(base, purpose, init), the only place a
request context is derived.

Each row reproduces the own-field set its site had, including the
background pass inheriting the request's render barrier (kept; open
question rango-t135-q1). The rows write fields with named stores: an
Object.assign of the capture's 30-odd fields left it in V8 dictionary
mode, and context-fast-properties.test.ts now pins fast properties for
every purpose. render-pass.bench.tsx times the derivations and a shell
MISS and HIT through serveShellRequest, using only seams that predate
render-pass.ts so it also runs against an earlier checkout.

check-internals: request-context-object-create lowered by 3, no raise.
Cut the comments from 167 of 300 lines to 62 of 195: one line per field
where its fresh-or-shared reason is not obvious, with the ticket or issue
reference kept (#684, #700, #1035, #1109). The background row now points
at rango-t160, and the #1109 note names the trigger: an Object.assign that
takes the object to 20 own properties.

Comment-only: both versions print identically through the TypeScript
printer with comments removed, and their token streams match.
@ivogt
ivogt merged commit d07eb1f into reorg/stack Oct 23, 2026
19 checks passed
@ivogt
ivogt deleted the reorg/s5-render-pass branch October 23, 2026 17:59
ivogt added a commit that referenced this pull request Oct 23, 2026
Brings in S16 (#1248, wire-params.ts WIRE), S5 (#1245, deriveRenderPass),
S15 (#1243, discovery phases), #1246 and #1247.

Conflicts, each resolved by keeping S14's structure and applying S16's
intent to it:

- rsc/handler.ts imports: kept the request-stages.js import; dropped the
  classifyRequest, RequestPlan and INTERNAL_RANGO_DEBUG imports S14 had
  already moved out; added S16's WIRE import, still read by the code that
  stayed in the handler (warmup, loader refuse, redirect tracking mode,
  isPartialAction).
- rsc/handler.ts response tail: kept the finalizeRequestResponse call; the
  inline partial/action interception S16 rewrote lives in
  request-stages.ts and now reads WIRE.partial and WIRE.action.
- rsc/handler.ts terminal plans: kept the terminalPlanResponse call; the
  action-rewrite check, partial redirect and version-mismatch log S16
  rewrote live in request-stages.ts and now read WIRE.action, WIRE.partial
  and WIRE.version.
- testing/dispatch.ts imports: added WIRE; dropped applyStreamIdleTimeout,
  which the stages now call.
- testing/dispatch.ts isPartial: kept S14's shape (the reverse and isAction
  moved into the stages) with S16's WIRE.partial.
- tools/internals-baseline.json: took the stack's version;
  check-internals --update leaves it unchanged (rsc-param-literal 4, all in
  use-loader.tsx).

request-stages.ts has no `_rsc_*` literal left. The handler and stages
together make the same searchParams calls as S16's handler.ts: has() on
action x3, partial x3, loader and warmup; get() on loader and version.
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