Skip to content

feat(router): support ctx.rendered() on streaming (loading()) trees - #553

Merged
ivogt merged 6 commits into
mainfrom
feat/rendered-under-streaming
Jun 9, 2026
Merged

ivogt merged 6 commits into
mainfrom
feat/rendered-under-streaming

Conversation

@ivogt

@ivogt ivogt commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

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 used loading():

ctx.rendered() is not supported when the matched route tree uses loading()...

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)

// urls.tsx — a streaming route whose handler pushes product IDs
path("/shop", ShopPage, { name: "shop" }, () => [
  loading(<Skeleton />),          // <-- streaming boundary
  loader(LivePricesLoader),
]);

async function ShopPage(ctx) {
  const push = ctx.use(Products);
  const ids = await fetchVisibleProductIds();   // async handler (streams behind loading())
  ids.forEach(push);
  return <ProductGrid ids={ids} />;
}

export const LivePricesLoader = createLoader(async (ctx) => {
  "use server";
  await ctx.rendered();             // before: THREW because the tree has loading()
  const ids = ctx.use(Products);    // after: complete IDs, incl. the streamed handler's
  return getLivePrices(ids);
});
Before After
rendered() in a loading() tree throws RenderedBarrierError waits for the streaming handlers to settle, then resolves
handle data the loader sees n/a (threw) complete — includes pushes from handlers behind loading()
non-streaming trees resolves after handlers settle unchanged
handler-invoked loader calling rendered() throws throws (unchanged)
a handler awaits the same loader (would deadlock) throws throws — the real cycle guard, unchanged

Why it's safe / the insight

A loading() handler runs eagerly — only its rendered output streams behind Suspense. Its full async execution (including push()) is already 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 snapshot while those handlers were still in flight.

Code change (minimal blast radius — loader-resolution.ts)

  • Removed the _treeHasStreaming reject guard.
  • rendered(): for streaming trees, after the barrier resolves, seal the store and await handleStore.settled before resolving. 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. 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).
  • Non-streaming path is byte-for-byte unchanged.

Test plan

  • Unit (rendered-barrier.test.ts): a streaming tree waits for a loading() 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)
  • E2e dev + production (rendered-barrier.test.ts): repurposed the old streaming-rejected route into /streaming — a loading() handler that pushes after an await (during streaming, past the barrier); the loader reads it and resolves prices. 7/7 in both modes.
  • Semantic matrix green in dev (18) and production (21) — no regression to execution guarantees.
  • typecheck / unit (3384) / lint / format clean. router.named-routes.gen.ts updated for the route rename.

Notes

  • Still out of scope (documented Known Limitations): promise values inside pushes (settled waits for the handler to push, not for a pushed Promise to resolve), and rendered() from a handler-invoked loader.
  • Doc overlap: docs/internal/rendered-barrier.md is 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.

…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.
@pkg-pr-new

pkg-pr-new Bot commented Jun 8, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: d821446

… 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.
@ivogt

ivogt commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author

P1 fixed: deadlock guard stays live during the streaming settle wait

Thanks — this was a real cycle. rendered() keeps waiting on handleStore.settled after the barrier resolves on a streaming tree, but _resolveRenderBarrier tore down the guard state (and the handler-side dependency tracking went dark once _renderBarrierSegmentOrder was set) — both at barrier time. So a loading() handler could resume during that window, await ctx.use(theSameLoader) undetected, and deadlock.

Fix (219183b5):

  • _resolveRenderBarrier: for streaming trees, keep _renderBarrierWaiters/_handlerLoaderDeps live and tear them down on handleStore.settled (by then every tracked handler has finished — none can await a loader). Non-streaming is unchanged. New _renderBarrierGuardClosed flag.
  • Direction-2 tracking now gates on _renderBarrierGuardClosed instead of _renderBarrierSegmentOrder, so it stays live across the settle wait.

Tests (your explicit ask + real-pipeline cover):

  • Unit: the mock _resolveRenderBarrier now mirrors the real cleanup (immediate for non-streaming, settled-deferred for streaming). New regression — a handler awaiting a still-waiting rendered() loader during the streaming window throws; it goes silent without the fix. 19/19.
  • E2e dev + production: new /streaming-deadlock route — a loading() handler awaits a rendered() loader; the guard must error, not hang (a reopened deadlock would time the test out). 8/8 both modes.
  • Semantic matrix still green (dev 18). DeadlockLoader had to be export const for $$id injection (the guard keys on loader.$$id) — flagged as a build warning that, unfixed, caused exactly the hang.

…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.
@ivogt

ivogt commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

P2 fixed: async push-callback continuations exempt from the deadlock guard

Good catch — this was a direct consequence of the P1 fix. insideHandlePush was a plain boolean (true only during the callback's synchronous call), so after an async push callback's first await it resumed with the flag false. Pre-P1 that was harmless (guard closed at the barrier); post-P1 the guard window stays open until handleStore.settled on streaming trees, so push(async () => { await x; return ctx.use(RenderedLoader); }) resuming during the settle window threw a false Deadlock. A pushed promise value isn't tracked by settled and can't block resolution — not a real cycle.

Fix (96dbb0a0):

  • New push-callback ALS scope in server/context.ts (runInsidePushCallbackScope / isInsidePushCallbackScope), mirroring the loader-scope ALS — unlike a boolean it survives the callback's awaits.
  • loader-resolution.ts runs the push callback inside that scope and gates the deadlock guard on isInsidePushCallbackScope() instead of the removed boolean.

Tests:

  • The 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.
  • Full unit 3386, e2e rendered-barrier dev + production 8/8 each, semantic matrix green.

ivogt added 3 commits June 9, 2026 07:14
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).
@ivogt
ivogt merged commit 68b96cf into main Jun 9, 2026
@ivogt
ivogt deleted the feat/rendered-under-streaming branch June 9, 2026 05:24
ivogt added a commit that referenced this pull request Jun 9, 2026
Bring in main (#553 ctx.rendered() on streaming/loading() trees, #554 prefetch
warm-decode, release 0.0.0-experimental.117). Clean merge — no conflicts.

Gate: typecheck 12/12, test:unit:all EXIT=0 (router 3544 + 6 todo, RSC 27),
lint 0/0, format.
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.

feat(router): support ctx.rendered() on streaming (loading()) route trees

1 participant