Skip to content

test(ppr): pin the outlined-bake guarantee — ready Suspense content bakes; masked holes postpone (#702) - #703

Merged
ivogt merged 1 commit into
mainfrom
test/ppr-outlined-bake-guarantee
Jul 6, 2026
Merged

ivogt merged 1 commit into
mainfrom
test/ppr-outlined-bake-guarantee

Conversation

@ivogt

@ivogt ivogt commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Problem statement

Issue #702 suspected that the PPR capture abort could land before fizz flushed outlined-but-ready Suspense content (any boundary >500 bytes gets fallback-first + outlined treatment in a tracked-postpones prerender), silently postponing content that had zero pending data — suspected as the router-level cause of a measured storefront pathology (~100 postponed boundaries, ~2.7MB re-rendered per HIT).

Finding: the defect does not exist — and the guarantee was unpinned

Control experiments (taps since removed) proved:

  • The capture already bakes outlined-ready content through the real abort path: a 30k-row synchronous section produced a 6.2MB prelude with postponed=null on a bare ppr route, and 6.2MB + 1.5KB postponed state beside a masked slot hole.
  • Ready fizz work cannot lose the abort race structurally: React's tracked-postpones pings run on scheduleMicrotask; the post-quiesce hops and abort ride setTimeout — every runnable task drains before the first hop fires.
  • The original red reproducer was doctrine-inconsistent: under route-level loading(), LoaderBoundary use()es the masked loader above the whole route subtree, so the entire body IS the hole — its rows were unreachable at capture (4.5KB prelude), not ready-but-unflushed.
  • A counted-abort gate (the issue's proposed direction) would be a no-op for ready work and would break the physics-hole doctrine (parked unmasked I/O must postpone).

The storefront pathology is the app-level shape (content under Suspense wrappers whose data situation makes them holes), cured by page structure — not by abort policy.

What a consumer sees

No behavior change (the one src edit is a comment documenting the microtask-atomicity invariant so a work-quiet/counted gate is not re-attempted). The consumer-visible contract this PR pins with tests, previously unpinned:

path("/page", Page, { ppr: true }, () => [ /* slot with loader+loading = the hole */ ])
// Page renders: <Suspense fallback={...}><BigSyncSection/></Suspense>  + <ParallelOutlet name="@hole"/>
  • <BigSyncSection/> (any size) bakes into the stored shell — served from the prelude on every HIT, zero per-request render;
  • the masked slot hole still postpones — fallback frozen in the prelude, fresh value streams per request;
  • and the doctrine clarification now lives in the fixture: content that must bake belongs beside the hole (slot/layout chrome), never under route-level loading() — under loading(), the whole route body is the hole by design.

Semantics table

Shape at capture Before After (pinned)
Outlined (>500B) sync Suspense content, data ready bakes (unpinned) bakes (test-pinned: rows in prelude before first </html>)
Masked loader hole (slot with loading()) postpones postpones (test-pinned: fallback in prelude, value only in resumed tail, seq advances per HIT)
Route-level loading() whole body = hole unchanged, now documented in the fixture

Code-change summary

  • e2e/test-app/src/urls/shell-cache.tsx — /shell-cache/outlined fixture: 10k-row outlined section + @outlinedBadge masked slot hole (own loader + loading()), ShellOutlinedChromeLayout; scar-tissue comments on the loading() shape.
  • e2e/test-app/src/urls/shell-cache.defs.ts — ShellOutlinedBadgeLoader (isolated seq counter).
  • e2e/shell-cache.test.ts — the guarantee test (byte-provenance via splitPrelude; runs in both dev and (production) describes via runShellCacheSpec).
  • src/ssr/index.tsx — comment-only: the microtask-atomicity invariant on the abort hop loop.
  • router.named-routes.gen.ts — regenerated (shellCacheOutlined).

Test plan

  • New guarantee test green in dev (2.6s) and production (859ms); red form reconfirmed across 6 tap runs including 3000-hop / 1.9s-quiet variants (4.5KB prelude).
  • Router unit: 326 files / 5052 passed; test:unit:all green across all packages.
  • shell-cache + shell-secure + shell-cache-action: 33 dev / 34 production.
  • prerender-ppr (producer B composition): 4 dev + 4 production.
  • cloudflare ppr battery: 27 passed (3 pre-existing producer-B dev-boot MISS-retry flakes on local; files untouched by this PR — flagged below).
  • typecheck 16/16, lint 0 warnings, format clean.

Notes

…akes into the shell; masked holes postpone (closes #702)

Investigation outcome (issue #702): the suspected defect does NOT exist.
Control experiments proved the capture already bakes outlined-but-ready
content through the real abort path (6.2MB prelude, postponed=null on a
bare ppr route; 6.2MB + 1.5KB postponed beside a masked slot hole), and
ready fizz work cannot lose the abort race structurally: React's
tracked-postpones pings run on scheduleMicrotask while the post-quiesce
hops and abort ride setTimeout, so every runnable task drains before the
first hop fires. A counted-abort gate would be a no-op for ready work and
would BREAK the physics-hole doctrine (parked unmasked I/O must postpone).

The original red reproducer was doctrine-inconsistent, not a bug repro:
under route-level loading(), LoaderBoundary use()es the masked loader
ABOVE the entire route subtree, so the whole body IS the hole — its 30k
rows were unreachable at capture (4.5KB prelude), not ready-but-unflushed.
React postpones whole Suspense boundaries, so bytes inside a postponed
boundary can never ride the prelude.

What this PR pins, on the reshaped /shell-cache/outlined fixture (10k-row
outlined section BESIDE a masked @outlinedBadge slot hole, never under
it):
- every outlined row lands in the stored prelude (byte-provenance via
  splitPrelude, rows 0 and 9999 before the first </html>);
- the masked hole still postpones (badge fallback frozen in the prelude,
  digit-anchored value only in the resumed tail, seq advances per HIT
  while the prelude stays byte-identical);
- dev + production describes both run it.

Also documents the microtask-atomicity invariant on the abort hop loop in
ssr/index.tsx (comment-only src change) so a work-quiet/counted gate is
not re-attempted, and the fixture carries the doctrine clarification:
content that must bake belongs beside the hole (slot/layout chrome), not
under route-level loading().

Battery: router unit 5052; shell suites 33 dev / 34 production;
prerender-ppr 4+4 (producer B composition); cloudflare ppr battery 27
passed (3 pre-existing producer-B dev-boot flakes, untouched files);
typecheck/lint/format green.
@pkg-pr-new

pkg-pr-new Bot commented Jul 6, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: c5def10

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.

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

1 participant