Skip to content

fix(router): PPR capture retry-in-place, quiet expected aborts, DSL middleware() attachment - #644

Merged
ivogt merged 1 commit into
mainfrom
feat/ppr-capture-dx
Jul 3, 2026
Merged

ivogt merged 1 commit into
mainfrom
feat/ppr-capture-dx

Conversation

@ivogt

@ivogt ivogt commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Problem

The just-merged PPR shell cache (#641) had three cold-start DX defects observed in a live dev session — all cosmetic (every route eventually HIT), but they made a healthy route look broken:

  1. A /shell-cache route with loading() (a perfectly capturable shape) needed ~5 MISS requests before it flipped to HIT, and every one of those requests dumped a full DOMException [AbortError] stack to the console.
  2. The once-per-key no usable shell warning fired for that route and blamed "a loader route without a route-level loading() boundary" — but the route had loading(). The real cause was cold modules, and the message sent the user hunting for a route-shape bug that didn't exist.

Root causes (both found by instrumenting a live dev server, not guessed):

  • The abort dumps were React's prerender default onError. Capture works by aborting (controller.abort() freezes the prelude and postpones the live holes); captureShellHTML passed no onError, so React logged the abort reason once per pending hole, on every capture that still had a live hole at abort time — i.e. every cold capture.
  • The multi-request warmup was cold vite module transforms outlasting the task-quantized quiesce, so the first capture froze an unfinished shell (trivial prelude, sanity gate refuses). The old code stored nothing and waited for the next HTTP request to try again; several requests each warmed a bit more until one stuck.

Before vs. after (the user's log transcript)

Cold GET /shell-cache (a loading()-equipped route), server output:

Before — ~5 MISS requests, two DOMException dumps per cold capture:

[ShellCache] MISS /shell-cache
DOMException [AbortError]: This operation was aborted
    at AbortController.abort (node:internal/abort_controller:506:18)
    at captureShellHTML (src/ssr/index.tsx:460:16)
    at captureAndStoreShell (src/rsc/shell-capture.ts:376:20)
    ... (x2, repeated across ~5 requests until HIT)

After — MISS then HIT in 1-2 requests, clean log (retry healed in the same background task, so no breadcrumb was even needed here):

[ShellCache] MISS /shell-cache
[ShellCache] HIT /shell-cache

For a route that genuinely can't capture (/shell-cache/no-hole, no loading()), the retry breadcrumbs and the rewritten warning replace the stack dumps:

[ShellCache] capture attempt 1/2 for .../shell-cache/no-hole:shell aborted before shell completed (cold modules?) — retrying
[ShellCache] capture attempt 2/2 for .../shell-cache/no-hole:shell aborted — giving up until next request
[rango] Shell capture for ".../shell-cache/no-hole:shell" produced no usable shell after an in-place retry ... Two things cause this, told apart by whether the route ever flips to HIT:
  1. Cold-start warmup ... This SELF-HEALS ...
  2. A loader route WITHOUT a route-level loading() boundary ... the route NEVER flips to HIT. Add loading() ...
See the /ppr skill (node_modules/@rangojs/router/skills/ppr/SKILL.md) ... or the design doc: https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/design/ppr-shell-resume.md

Consumer-facing changes

1. Attach the shell middleware via the urls() middleware() DSL

Route middleware wraps the render pass with a Response-returning next() — exactly the onion the shell middleware needs. It now works attached per-subtree, not only via router.use():

// before: global only
router.use("/products/*", createShellCacheMiddleware({ ttlSeconds: 600 }));

// after: also colocated, per-subtree, via the DSL
export const productPatterns = urls(({ path, layout, loader, loading, middleware }) => [
  layout(ProductShellLayout, () => [
    middleware(createShellCacheMiddleware({ ttlSeconds: 600, swrSeconds: 120 })),
    path("/products/:id", PricePage, { name: "product" }, () => [
      loader(LivePriceLoader),
      loading(<PriceSkeleton />),
    ]),
  ]),
]);

MISS -> HIT and HIT composition are byte-identical to the global attachment (proven by new dev+prod e2e in both apps). Actions bypass it for free (a POST is non-GET), and the background capture never re-enters it.

2. tags option for operational shell invalidation

createShellCacheMiddleware({
  tags: (ctx) => [`tenant:${ctx.get("tenant")}`],  // string[] | (ctx) => string[]
});

Unioned with the tags the capture auto-collects (every cacheTag(...) from shell material). Invalidating any tag a shell carries drops that shell -> MISS -> recapture.

Semantics table

Case Behavior
Capture attempt 1 returns null / aborts (cold modules) retry once after 400ms in the same background task
Capture attempt 1 throws a genuine (non-abort) error no retry — propagates to reportCacheError once
Both attempts fail (retry didn't heal) nothing stored; once-per-key warning fires after the retry; key enters backoff
React prerender abort reason (signal.aborted && AbortError) swallowed by capture's onError — no stack dump
Genuine shell render error during capture still surfaces via deps.onError
Refused/failed key, backoff window exponential (1s, 2s, 4s … capped 60s); re-probed once the window elapses; a success clears it
Cold-but-eligible route trips the backoff recovers on the next request or two (window starts at 1s), not frozen 60s
JS action mutating hole (loader) data client applies result; next hard GET stays HIT, hole shows the new value (loaders live under PPR — no invalidation)
JS action mutating shell material + updateTag(tag) hard GET MISS (tag cascade dropped the shell) -> recapture with new shell in the prelude
Non-GET (action POST) on a PPR route (global or DSL) shell middleware bypasses; response untouched; no capture scheduled
No-JS PE form POST to a PPR route axis-1 render (formState); x-rango-shell absent/MISS on the POST
Shell middleware invoked inside a capture render (_shellCaptureRun) inert — passthrough next(), no getShell, no flags armed

Code-change summary

  • src/ssr/index.tsx — createShellCaptureHandler threads deps.onError; prerender gets an onError that swallows the deliberate abort and reports genuine errors. This is the fix for the DOMException noise — it lives at the prerender call because that's the exact source (React's default onError).
  • src/rsc/shell-capture.ts — runShellCapture is a two-attempt loop (attemptCapture x2, 400ms delay); captureAndStoreShell returns a discriminated outcome (stored | no-shell, or throws a genuine error) instead of warning itself; concise debug breadcrumbs gated on the descriptor's debug; rewritten once-per-key warning (both causes + shipped-safe pointer); exponential refused-capture backoff in scheduleShellCapture; option-tags union collected-tags in attemptCapture.
  • src/cache/shell-cache.ts — tags option; debug + tags threaded onto the _shellCapture descriptor; _shellCaptureRun bypass guard at the top of the handler (scar tissue for DSL attachment).
  • src/server/request-context.ts — debug field on the _shellCapture descriptor type.
  • Docs: docs/design/ppr-shell-resume.md (retry-in-place + exponential backoff); skills/ppr/SKILL.md (Attaching via middleware(), Invalidation: tags vs revalidate(), cold-start pitfall, tags option).

Deliverable 4(a) verification (definitive)

Route middleware attached via urls() middleware() does NOT re-run inside the background capture. router.match() only collects route middleware into the MatchResult (src/router/match-result.ts:258); it is executed by the RSC handler's executeRender() (src/rsc/handler.ts:1198-1221), which the background capture bypasses entirely (runShellCapture calls ctx.router.match() directly, then buildFullPayload + Flight render — never executeMiddleware). The _shellCaptureRun bypass guard makes inertness inside any capture render a hard invariant regardless of attachment or future changes.

Test plan

  • Unit (router): retry cases a-d (null->heal, abort->retry, non-abort->no retry+report once, both fail->warn once); captureAndStoreShell outcome contract; exponential backoff (window + expiry + success-clears); option-tags union collected-tags into putShell; middleware _shellCaptureRun bypass (no getShell, no flags); descriptor debug/tags threading. pnpm run test:unit:all green (router 4791 + all consumer dogfood suites).
  • e2e dev + production (test-app): existing shell-cache suite (regression gate — the retry touches every capture path) 10 dev / 9 prod; new shell-cache-dsl 4/4 both; new shell-cache-action (hole mutation, shell+updateTag, DSL POST bypass, no-JS PE) 4 dev / 5 prod.
  • e2e dev + production (cloudflare-basic): ppr-shell 13 dev / 12 prod incl. the new DSL describe.
  • typecheck (repo, 15/15), lint (0/0), format, check:e2e-bucketing OK.
  • Manual smoke (the user's complaint, reproduced on the fix): cold /shell-cache -> MISS->HIT at GET2, no DOMException dumps, no warning; /shell-cache/no-hole -> concise retry breadcrumbs + rewritten warning + shipped-safe pointer, no dumps.

Notes / call-outs

  • Runtime doc pointers now use the shipped-path + GitHub-URL convention (a05c825). The only runtime-emitted docs/ string was the no usable shell warning; it now points at the /ppr skill (ships in the tarball) plus the absolute GitHub URL for the design doc. JSDoc/code comments keep repo-relative paths (read in-repo). A sweep of src/ confirmed no other runtime strings carried an unshipped docs/ path.
  • Exponential (not flat) backoff was a mid-review correction. A flat 60s backoff made the cloudflare dev PPR e2e time out: under cold miniflare + parallel load a cold-but-eligible route could fail the retry, and a flat 60s window blocked warmToHit's multi-request recovery — re-breaking the very cold-start DX this PR fixes. Escalating from 1s lets eligible routes recover immediately while a truly doomed route ramps to the 60s cap. Production (pre-built modules -> capture on attempt 1) never trips it.
  • A native <form action={serverAction}> drifts in a frozen shell (React encodes server-action form fields differently between the capture and serve render). The 10(c) PE fixture therefore lives on its own /shell-cache-action/pe route exercised only no-JS (nothing hydrates), documented inline; the JS-action routes keep their forms out of the frozen shell.
  • cloudflare-basic scope: got the cheap subset — the DSL attachment (4c) + nav links. The full action-correctness suite (10a-d) stays in test-app (the mandated app); porting the cached+tagged-shell / PE / mutable-state fixtures to cloudflare's KV store was not "cheap" and would duplicate coverage.

…iddleware() attachment

The just-merged PPR shell cache had three cold-start DX defects: a
loading()-equipped route needed ~5 MISS requests before HIT, each dumping a
full DOMException AbortError stack; and the once-per-key warning blamed a
missing loading() boundary even when the real cause was cold modules.

- Retry-in-place: runShellCapture now runs attemptCapture up to twice with a
  400ms delay; a null/aborted first attempt (cold modules) usually heals on the
  retry in the same background task, so cold-start reaches HIT in 1-2 requests.
- Quiet expected aborts: pass prerender an onError that swallows the deliberate
  capture abort (React's default logged one DOMException per pending hole). One
  concise "[ShellCache] capture attempt N/2 ..." breadcrumb per attempt under the
  middleware debug flag (threaded via the _shellCapture descriptor).
- Warning rewrite: names BOTH causes (cold-start self-heals vs missing loading())
  with the distinguishing signal, fires only after the retry, and uses a
  shipped-safe doc pointer (skill path + GitHub URL, per a05c825).
- Refused-capture backoff: an ineligible route is negatively cached with an
  EXPONENTIAL window (1s..60s) after the retry also fails, so app-wide mounting
  never re-renders a doomed route every request; a cold-but-eligible route
  recovers on the next request or two.
- DSL middleware() attachment: verified route middleware only runs in the RSC
  handler's executeRender (not router.match), so it never re-enters the capture;
  added a _shellCaptureRun bypass guard as a hard invariant. New DSL-attached e2e
  subtrees in test-app and cloudflare-basic.
- tags option on ShellCacheOptions (string[] | (ctx)=>string[]) unioned with the
  auto-collected shell tags.
- Action-correctness e2e (test-app): JS action on hole data, JS action on
  cached+tagged shell material + updateTag, no-JS PE POST, DSL POST bypass.
- Nav links for the PPR routes in both apps.
@pkg-pr-new

pkg-pr-new Bot commented Jul 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 563e752

@ivogt
ivogt merged commit e3b274a into main Jul 3, 2026
30 checks passed
@ivogt
ivogt deleted the feat/ppr-capture-dx branch July 3, 2026 14:04
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.

1 participant