Repository navigation
feat(router): support ctx.rendered() on streaming (loading()) trees - #553
Conversation
…548) Completes the render-barrier feature: a DSL loader can now `await ctx.rendered()` and read handle data on a route tree that uses `loading()`, instead of the call throwing. Closes #548. The key realization is that the signal already exists. A loading() handler runs EAGERLY — only its rendered output streams behind Suspense — and its full async execution (including its push() calls) is tracked in the handle store (fresh.ts: trackHandler(handler(context)) -> store.track). So handleStore.settled (sealed AND no in-flight tracked promises) already waits for streaming handlers. The only bug was that the barrier resolved synchronously right after resolveAllSegments(), building an incomplete handle snapshot while those handlers were still in flight. Change (loader-resolution.ts), minimal blast radius: - Remove the `_treeHasStreaming` reject guard. - rendered(): for streaming trees, after the barrier resolves, seal the handle store and `await handleStore.settled` before resolving. The seal is safe — every handler is tracked during resolveAllSegments, so none is unregistered by barrier time; pushes happen within those tracked promises (before settled); the payload's later stream() auto-seal is idempotent. - ctx.use(handle): for streaming trees build a fresh snapshot from the now-complete store (the eager barrier snapshot is incomplete for streaming). - Keep the handler-awaits-loader deadlock guard (the real cycle guard) and the handler-invoked-loader guard. - Non-streaming path is byte-for-byte unchanged. Tests: - Unit (rendered-barrier.test.ts): streaming tree now waits for a loading() handler's deferred push and reads complete data; deadlock guard still rejects. - E2e (rendered-barrier.test.ts, dev + production): repurposed the old "streaming-rejected" route into "/streaming" — a loading() handler that pushes its handle data AFTER an await (during streaming, past the barrier); the loader reads it and resolves prices. Verified the semantic matrix stays green in both modes. Docs: rendered-barrier.md status/scope/guard-table updated; "Why Not Streaming" reframed to "How Streaming Works". loader-types.ts rendered() doc drops the non-streaming restriction. router.named-routes.gen.ts updated for the route rename.
commit: |
… settle wait Review P1: on a streaming tree, rendered() keeps waiting on handleStore.settled AFTER the render barrier resolves — but _resolveRenderBarrier tore down the guard state (_renderBarrierWaiters / _handlerLoaderDeps) and the handler-side dependency tracking went dark once _renderBarrierSegmentOrder was set, both at barrier time. So a loading() handler could resume during that wait, `await ctx.use(theSameLoader)` undetected, and deadlock (the handler waits for the loader; the loader waits for settled, which waits for the handler). That violated the "handler awaits the same loader throws" contract. Fix: - request-context.ts _resolveRenderBarrier: for streaming trees, do NOT clear the guard state at barrier time. Keep it live and tear it down on handleStore.settled (by then every tracked handler has finished — none can await a loader anymore). Non-streaming is unchanged (clears immediately). Added _renderBarrierGuardClosed. - loader-resolution.ts: gate the Direction-2 dependency tracking on _renderBarrierGuardClosed instead of _renderBarrierSegmentOrder, so it stays live across the streaming settle wait. Direction-1 now sees the kept waiters. Tests: - Unit (rendered-barrier.test.ts): the mock _resolveRenderBarrier now MIRRORS the real cleanup (immediate for non-streaming, settled-deferred for streaming). New regression: a handler that awaits a still-waiting rendered() loader DURING the streaming settle window throws (goes silent without the fix). 19/19. - E2e (rendered-barrier.test.ts, dev + production): new /streaming-deadlock route — a loading() handler awaits a rendered() loader; the guard must error, not hang (a regression would time the test out). DeadlockLoader is `export const` so the expose-internal-ids transform injects its $$id (the guard keys on loader.$$id). - Semantic matrix green; router.named-routes.gen.ts updated for the new route.
P1 fixed: deadlock guard stays live during the streaming settle waitThanks — this was a real cycle. Fix (
Tests (your explicit ask + real-pipeline cover):
|
…ing deadlock guard
Review P2: the handler-to-loader deadlock guard used a plain `insideHandlePush`
boolean that is only true during a push callback's SYNCHRONOUS call. After an
async push callback's first await it resumes with the flag false. Before the P1
fix that was harmless (the guard window closed at the barrier), but P1 keeps
_renderBarrierWaiters live until handleStore.settled on streaming trees — so a
callback like push(async () => { await x; return ctx.use(RenderedLoader); }) that
resumes during the settle window would throw a false "Deadlock". A pushed promise
value is not tracked by settled and cannot block segment resolution, so it is not
a real cycle.
Fix:
- server/context.ts: add a push-callback ALS scope (runInsidePushCallbackScope /
isInsidePushCallbackScope), mirroring the loader-scope ALS. Unlike a boolean, it
survives the callback's awaits.
- loader-resolution.ts: run the push callback inside runInsidePushCallbackScope and
gate the deadlock guard on isInsidePushCallbackScope() instead of the removed
boolean — so an async callback's continuation stays exempt.
Tests:
- Unit (rendered-barrier.test.ts): streaming counterpart of the existing
"push callback resumes after barrier" test — an async push callback that calls
ctx.use(loader) DURING the streaming settle window must NOT throw. The mock pulls
the REAL push-callback ALS via importActual so scope-survives-await is exercised
faithfully (it throws without the fix). 20/20.
- Updated loader-cycle-detection.test.ts's context mock with the two new exports.
- Full unit (3386), e2e rendered-barrier dev + production (8/8 each), semantic
matrix green.
P2 fixed: async push-callback continuations exempt from the deadlock guardGood catch — this was a direct consequence of the P1 fix. Fix (
Tests:
|
Cleanup pass over the streaming rendered() changes (no behavior change): - Cache the streaming handle snapshot once. rendered() now builds the complete snapshot after handleStore.settled and stores it in _renderBarrierHandleSnapshot, so ctx.use(handle) reads the cached snapshot uniformly instead of rebuilding it on every call for streaming trees. This also collapses the _treeHasStreaming ternary in ctx.use(handle) to a single `cached ?? buildHandleSnapshot(...)` expression — the snapshot's presence is now the only signal, with streaming vs non-streaming living solely at the point that decides when to populate it. - Extract a closeGuard() closure in _resolveRenderBarrier so the three-line guard teardown is written once; the only streaming/non-streaming difference left is WHEN it runs (now vs. settled.then(closeGuard)). - Fix stale comments that referenced the removed `insideHandlePush` boolean (now the isInsidePushCallbackScope() ALS scope), in loader-resolution.ts and the push-callback unit test. Verified: typecheck, full unit (3386), rendered-barrier e2e dev + production (8/8 each), lint, format.
…eplay Closes the one rendered()-under-streaming path that was asserted only by reasoning (flagged in the PR review): a loading() tree whose handler output and streamed handle data are REPLAYED (runtime cache hit, or build-time prerender), where the live loader calls rendered() on the replay path. Two new test-app routes + e2e (dev + production): - /streaming-cached — loading() handler under cache(). First request renders fresh (streaming, deferred push); the second is a cache HIT. Asserts the handler ts is unchanged (cached) and the live loader's rendered() still reads the replayed handle data (widget-a/widget-b prices), proving the cache captured the streamed push and replay feeds rendered() correctly. - /streaming-prerender — loading() handler that is build-time prerendered (build log confirms `[rango] OK /rendered-barrier/streaming-prerender`). At runtime the replayed handle data feeds the live loader's rendered(). Both verified green in dev and production; full rendered-barrier suite now 10/10 each mode. router.named-routes.gen.ts updated for the two routes.
…p ci] The loader skill is the only consumer-facing doc for ctx.rendered(); its row predated streaming support and didn't say whether loading() was safe. Update it to state that rendered() waits for all non-loader segments — including loading() streaming handlers — to settle before handle data is readable. Markdown only (oxfmt re-aligned the Loader Context table). The internal contract doc (docs/internal/rendered-barrier.md) and the loader-types.ts JSDoc were already updated in this PR; feature-map/feature-file-map need no change (no new source files, and the new ALS helpers are module-internal, not public exports).
Closes #548.
Problem
ctx.rendered()lets a DSL loader wait for the handler tree to settle and then read handle data (ctx.use(handle)). Until now it threw on any route tree that usedloading():So the moment a consumer added a streaming boundary anywhere in the matched tree, a loader that relied on
rendered()broke — they had to choose between streaming and reading handler-produced handle data in a loader.Before vs. after (consumer)
rendered()in aloading()treeRenderedBarrierErrorloading()rendered()awaits the same loader (would deadlock)Why it's safe / the insight
A
loading()handler runs eagerly — only its rendered output streams behind Suspense. Its full async execution (includingpush()) is already tracked in the handle store (fresh.ts:trackHandler(handler(context)) → store.track). SohandleStore.settled(sealed AND no in-flight tracked promises) already waits for streaming handlers. The only bug was that the barrier resolved synchronously right afterresolveAllSegments()— building an incomplete snapshot while those handlers were still in flight.Code change (minimal blast radius —
loader-resolution.ts)_treeHasStreamingreject guard.rendered(): for streaming trees, after the barrier resolves, seal the store andawait handleStore.settledbefore resolving. Seal is safe — every handler is tracked duringresolveAllSegments, so none is unregistered by barrier time; pushes happen within those tracked promises (beforesettled); the payload's laterstream()auto-seal is idempotent. The loader's own segment streams in after, so the shell is not blocked; the handler-awaits-loader deadlock guard prevents the only real cycle.ctx.use(handle): for streaming trees, build a fresh snapshot from the now-complete store (the eager barrier snapshot is incomplete for streaming).Test plan
rendered-barrier.test.ts): a streaming tree waits for aloading()handler's deferred push (the assertion would see[]if it resolved early) and reads complete data; the deadlock guard still rejects under streaming. (18/18)rendered-barrier.test.ts): repurposed the oldstreaming-rejectedroute into/streaming— aloading()handler that pushes after anawait(during streaming, past the barrier); the loader reads it and resolves prices. 7/7 in both modes.router.named-routes.gen.tsupdated for the route rename.Notes
settledwaits for the handler to push, not for a pushedPromiseto resolve), andrendered()from a handler-invoked loader.docs/internal/rendered-barrier.mdis also touched by the open docs-warming PR docs(router): warm the 9 explanation/"why" docs into the onboarding voice #549. This PR updates the same file's factual claims (status/scope/guard table; "Why Not Streaming" → "How Streaming Works"). Whichever merges second needs a small resolution — take the warmed voice and the streaming-supported facts.