Skip to content

PPR capture: outlined-but-ready Suspense content postpones — counted-abort gate needed (large sync sections re-render per HIT) #702

Closed

Description

@ivogt

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_postpone signal and the prerender runs to natural completion, so outlined content always flushes. cacheComponents mode uses a two-phase render with a refcount signal (CacheSignal, incl. tracked import()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

Activity

  1. ivogt commented on Jul 6, 2026

    @ivogt
    CollaboratorAuthor

    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.

  2. added a commit that references this issue on Jul 6, 2026
    a12b3e6
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions