Repository navigation
fix(ppr): capture lane gets its own render barrier — rendered() no longer reads the foreground handle snapshot (#684 plan 009) - #704
Merged
Conversation
…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.
11 tasks
commit: |
ivogt
marked this pull request as ready for review
July 6, 2026 17:44
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.
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 — noloading()on its entry, so it executes during the background shell capture) read the WRONG request's handle data during capture: the capture context isObject.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) andctx.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:rendered()+ctx.use(handle)rendered()ctx.use(handle)afterrendered()loading()elsewhere in the tree)_treeHasStreamingsealed the capture's fresh store at loader start, snapshot paired capture store with foreground segment order_treeHasStreamingrecomputed for the capture's tree; seal + settle follow the capture's own lifecycle_renderBarrierWaiters,_handlerLoaderDeps,_renderBarrierGuardClosed)deriveShellCaptureContextCode change
packages/rangojs-router/src/server/request-context.ts— the barrier closure (lazy_renderBarriergetter,_resolveRenderBarrier, guard teardown) moves verbatim fromcreateRequestContextinto an exportedwireRenderBarrier(ctx, handleStore), which additionally resets the six barrier-family fields as own properties (no-op for a fresh context, shadowing for a derived one)._renderBarrieris installed viadefineProperty, 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—deriveShellCaptureContextcallswireRenderBarrier(derivedCtx, freshHandleStore)right after installing the fresh store; theattemptCapturedoc 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.tsnext to the fields it manages (the closure needsbuildHandleSnapshotand 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
packages/rangojs-router/src/router/__tests__/shell-capture-barrier-isolation.test.ts— drives the REAL match path (createRequestContext→ realderiveShellCaptureContext→router.matchunderrunWithRequestContext), no mocks of the barrier. Pins: (1) capture-lanerendered()+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-lanerendered()waits for the CAPTURE's streamed push; (4) foreground semantics unchanged by the extraction.wireRenderBarrier(derivedCtx, freshHandleStore)call removed, 3 of 4 fail — headline failureexpected [['stream-1'], ['stream-1']] to deeply equal [['stream-1'], ['stream-2']](capture loader read the foreground's data). With the call: 4/4 pass.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).git status --porcelain | grep '\.gen\.ts'empty.Notes
rendered-barriertest-app routes as a follow-up._treeHasStreamingreset matters beyond the flag itself:cache-lookup.ts/segment-resolution.tsonly compute it whenundefined, so the inherited foreground value previously suppressed the capture's own computation.runLoadertesting primitive is unaffected — it deliberately mocksrendered()(documented insrc/testing/run-loader.ts); the real barrier requires the render pipeline, which is why the new test drivesrouter.matchinstead.