Skip to content

fix(ppr): capture lane gets its own render barrier — rendered() no longer reads the foreground handle snapshot (#684 plan 009) - #704

Merged
ivogt merged 2 commits into
mainfrom
fix/capture-barrier-isolation
Jul 6, 2026
Merged

ivogt merged 2 commits into
mainfrom
fix/capture-barrier-isolation

Conversation

@ivogt

@ivogt ivogt commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Closes out plan 009 from #684 (capture derived-context render-barrier inheritance — CONFIRMED in the investigation).

Problem statement

A DSL loader on a PPR route that does await ctx.rendered(); ctx.use(handle) (bake lane — no loading() on its entry, so it executes during the background shell capture) read the WRONG request's handle data during capture: the capture context is Object.create(reqCtx), and the entire _renderBarrier* family fell through the prototype to the foreground request's barrier. That barrier's getter and resolver are closure-bound to the foreground context and its handle store, and the resolver no-ops once resolved — so during capture, rendered() resolved instantly (the foreground barrier had already resolved) and ctx.use(handle) returned the foreground's handle snapshot. The capture's fresh _handleStore — the one the capture's own handlers push into — was invisible to the loader.

Consumer-observable consequence: whenever a handler pushes non-deterministic or request-derived handle data, the baked loader value inside the shared shell embedded the TRIGGERING request's handle data instead of the capture render's own — an internal inconsistency between what the shell's handlers rendered and what its baked loader saw. On a streaming tree the inherited state was worse: rendered() proceeded immediately with the foreground's _treeHasStreaming === true, sealing the capture's fresh store at loader start and pairing it with the foreground's segment order.

Before vs. after

The consumer writes nothing differently — the fix changes what rendered() observes during capture:

// A PPR route: handler pushes request-derived handle data,
// a bake-lane loader reads it after the render barrier.
const ProductsPage = (ctx) => {
  const push = ctx.use(RenderedProducts);
  push(personalizedProductId(ctx.request)); // per-request value
  return <Products />;
};

export const LivePricesLoader = createLoader(async (ctx) => {
  "use server";
  await ctx.rendered();
  const ids = ctx.use(RenderedProducts); // ← the fix changes what this sees during capture
  return fetchPrices(ids);
});
Lane Before After
Foreground render, rendered() + ctx.use(handle) own barrier, own snapshot unchanged
Shell capture, bake-lane loader rendered() resolves instantly against the FOREGROUND's barrier waits for the CAPTURE's segment resolution
Shell capture, ctx.use(handle) after rendered() FOREGROUND handle snapshot CAPTURE handle snapshot (fresh store)
Shell capture, streaming tree (loading() elsewhere in the tree) inherited _treeHasStreaming sealed the capture's fresh store at loader start, snapshot paired capture store with foreground segment order _treeHasStreaming recomputed for the capture's tree; seal + settle follow the capture's own lifecycle
Shell capture, deadlock-guard fields (_renderBarrierWaiters, _handlerLoaderDeps, _renderBarrierGuardClosed) inherited foreground guard state (window already closed) reset; capture runs its own guard window
Build-time prerender capture (producer B) same inheritance through deriveShellCaptureContext same fix — one derivation serves both producers

Code change

  • packages/rangojs-router/src/server/request-context.ts — the barrier closure (lazy _renderBarrier getter, _resolveRenderBarrier, guard teardown) moves verbatim from createRequestContext into an exported wireRenderBarrier(ctx, handleStore), which additionally resets the six barrier-family fields as own properties (no-op for a fresh context, shadowing for a derived one). _renderBarrier is installed via defineProperty, not assignment — on a derived context the prototype's property may already be a pinned non-writable data property, which rejects plain assignment.
  • packages/rangojs-router/src/rsc/shell-capture.ts — deriveShellCaptureContext calls wireRenderBarrier(derivedCtx, freshHandleStore) right after installing the fresh store; the attemptCapture doc block's accumulator list gains the barrier family. Both shell producers (runtime capture and build-time prerender capture, PPR producer B: emit the shell entry at BUILD time for prerendered routes (Prerender + ppr = zero runtime capture) #699) go through this derivation.
  • packages/rangojs-router/docs/internal/rendered-barrier.md — the Render Barrier Lifecycle section gains the PPR capture lane with the scar tissue.

Location rationale: the wiring lives in server/request-context.ts next to the fields it manages (the closure needs buildHandleSnapshot and the exact teardown semantics); shell-capture only calls it. Duplicating the closure in shell-capture would let the two lifecycles drift — the investigation's exact failure class.

Test plan

  • New packages/rangojs-router/src/router/__tests__/shell-capture-barrier-isolation.test.ts — drives the REAL match path (createRequestContext → real deriveShellCaptureContext → router.match under runWithRequestContext), no mocks of the barrier. Pins: (1) capture-lane rendered() + ctx.use(handle) reads the capture's push, not the foreground snapshot; (2) the derived context's resolver/segment order/snapshot/streaming flag are its own and the foreground's state is untouched by the capture; (3) streaming-tree variant — capture-lane rendered() waits for the CAPTURE's streamed push; (4) foreground semantics unchanged by the extraction.
  • Red-before-green: with the wireRenderBarrier(derivedCtx, freshHandleStore) call removed, 3 of 4 fail — headline failure expected [['stream-1'], ['stream-1']] to deeply equal [['stream-1'], ['stream-2']] (capture loader read the foreground's data). With the call: 4/4 pass.
  • Existing barrier + capture suites green: rendered-barrier.test.ts, shell-capture-live-lane-ctx-use.test.ts, shell-capture-telemetry-suppression.test.ts, rsc/__tests__/shell-capture*.test.ts, capture-queue.test.ts (79 tests).
  • pnpm run typecheck (16/16), pnpm run test:unit:all (exit 0), pnpm run lint (0 warnings), pnpm run format (clean).
  • No route changes — git status --porcelain | grep '\.gen\.ts' empty.

Notes

…nger reads the foreground handle snapshot (#684 plan 009)

The shell capture's derived context is Object.create(reqCtx), and the
_renderBarrier* family was never overridden: the barrier getter and
resolver are closure-bound to the foreground context and its handle
store, and the resolver no-ops once resolved. A bake-lane loader's
`await ctx.rendered()` during capture resolved instantly against the
foreground's already-resolved barrier and ctx.use(handle) read the
FOREGROUND handle snapshot — the capture's fresh _handleStore was
invisible, so foreground per-request handle data could bake into the
shared shell. On a streaming tree the inherited state also sealed the
capture's fresh store at loader start.

Fix: extract the barrier wiring from createRequestContext into
wireRenderBarrier(ctx, handleStore) and call it from
deriveShellCaptureContext, resetting _renderBarrierSegmentOrder/
Waiters/HandleSnapshot/GuardClosed, _handlerLoaderDeps and
_treeHasStreaming as own properties. The capture now runs the same
barrier lifecycle as a fresh render, for both shell producers (runtime
capture and build-time prerender capture).

Red-before-green: shell-capture-barrier-isolation.test.ts drives the
real match path (createRequestContext + deriveShellCaptureContext +
router.match) — 3 failed without the wiring call, 4/4 pass with it.
Internal doc rendered-barrier.md gains the capture-lane lifecycle.
@pkg-pr-new

pkg-pr-new Bot commented Jul 6, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@704

commit: 51807ff

@ivogt
ivogt marked this pull request as ready for review July 6, 2026 17:44
@ivogt
ivogt merged commit cc83051 into main Jul 6, 2026
34 checks passed
@ivogt
ivogt deleted the fix/capture-barrier-isolation branch July 6, 2026 17:54
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