Repository navigation
Description
Problem
Fizz OUTLINES any Suspense boundary over ~500 bytes (react-dom isEligibleForOutlining), and in a tracked-postpones prerender renderSuspenseBoundary writes the FALLBACK inline first with the content as a queued task. The capture abort (quiesce → payloadSettled → 16 task hops) can land before that content flushes, postponing boundaries whose content was fully renderable with zero pending data. Every HIT then re-renders and re-streams those sections.
Measured on a production storefront homepage (2026-07-06): ~100 postponed boundaries, ~2.7MB of resume HTML regenerated per request at ~5MB/s workerd throughput — the entire post-shell latency tail (~400ms). App-side workaround shipped there (drop <Suspense> around plain values), but any consumer wrapping large synchronous content in Suspense hits this silently.
What was tried and disproven
A work-quiet abort gate (grant task turns while inter-hop timer gaps show work; abort after K idle hops): on the red reproducer the gate observed 16/16 idle hops while the capture stored a 4.5KB prelude — fizz was not queued behind task turns, it was suspended (the fixture's 30k-row section serializes as one huge flight row; fizz blocked on the row thenable, and the flight-gate/SSR interplay for oversized single rows needs its own look). Timer-gap sensing cannot distinguish "blocked on masked data" (correct to abort) from "blocked on capture-machinery starvation" (wrong to abort).
Direction
Counted/tracked-abort semantics instead of physics:
- Next.js: classic PPR has NO abort — dynamic-ness is a synchronous
unstable_postponesignal and the prerender runs to natural completion, so outlined content always flushes.cacheComponentsmode uses a two-phase render with a refcount signal (CacheSignal, incl. trackedimport()s — PR [dynamicIO] cache tracking for import() vercel/next.js#74152) and aborts at a fixed task boundary only after the count drains. - vinext (cloudflare/vinext): explicit pending-task counting + an epoch guard against late settles.
- For rango: the capture knows its masked promises (loader masks, nested-thenable masks). Everything else pending should be allowed to settle/flush before abort — i.e., abort when (pending − masked) = 0 at a task boundary, deadline-bounded. That makes outlined-but-ready content bake by construction and is the structural successor to the quiet-window + payload-settled patches (fix(vite): prerender collection ran trieless — catch-all shadowed nested routes; Prerender + ppr composition coverage #697).
Red reproducer (in-tree, test.fixme)
packages/rangojs-router/e2e/shell-cache.test.ts — "large sync Suspense section bakes into the shell" + the /shell-cache/outlined fixture (e2e/test-app/src/urls/shell-cache.tsx): a 30k-row synchronous Suspense section next to a normal loader hole; asserts the rows arrive in the same flush window as the first byte of a HIT. Currently red: the capture stores a 4.5KB prelude and ~20MB streams per request. Enable with the fix. Note the fixture also exposes the single-giant-flight-row interaction — worth splitting into a many-medium-boundaries variant (the storefront shape) when picking this up.
Sequencing
After #701 (producer B) lands — same files (ssr/index.tsx, shell-capture.ts, rsc-rendering.ts), and build-time captures benefit from the same gate.
References
- fix(vite): prerender collection ran trieless — catch-all shadowed nested routes; Prerender + ppr composition coverage #697 (payload-settled gate — the reactive patch for one instance of this class), feat(ppr): shell fast path — HIT tails replay the handler layer; only loaders are dynamic #695, PPR fast path: emit stored snapshot fragments verbatim into the HIT payload (stop re-serializing baked segments per request) #700, feat(ppr): producer B — emit the shell entry at build time for prerendered routes (#699) #701.
- Framework research memos (2026-07-06 session): Next.js prerender/postpone internals, vinext counted-abort, Waku slot model.
Activity
ivogt commentedon Jul 6, 2026
Investigated and refuted — the capture already bakes outlined-but-ready content, and ready fizz work cannot structurally lose the abort race (microtask pings vs setTimeout hops). The guarantee is now pinned by tests, and the doctrine clarification (content that must bake belongs beside the hole, not under route-level loading()) is documented in the fixture. See the PR for full evidence and control experiments.
test(ppr): pin the outlined-bake guarantee — ready Suspense content b…