Skip to content

fix(router): shell capture no longer waits for loader pushes its record leaves out (#1035) - #1059

Merged
ivogt merged 3 commits into
mainfrom
fix/capture-skip-unrendered-push-wait
Oct 4, 2026
Merged

ivogt merged 3 commits into
mainfrom
fix/capture-skip-unrendered-push-wait

Conversation

@ivogt

@ivogt ivogt commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator

Problem

Since #1054 a shell keeps only the handle pushes its record keeps; a promise pushed by an ssr: false loader is live, has no slot in the prelude, and arrives after hydration. The capture still waited for that promise before writing the record. If it settled after ppr.captureTimeout, or never (it depends on a masked live loader), the capture was refused and the route lost its shell for a value the shell does not contain.

Before / after

const SlowLoader = createLoader(async (ctx) => {
  ctx.use(Notes)("settled");
  ctx.use(Notes)(fetchSomethingSlow()); // settles after ppr.captureTimeout
  return { value: "x" };
});

path("/p", Page, { name: "p", ppr: { captureTimeout: 300 } }, () => [
  loader(SlowLoader, { ssr: false }),
]);
// before: capture refused, every request is a MISS
// after:  shell stored; HIT HTML has the "settled" row; the slow value
//         shows after hydration, delivered by the HIT's own loader run

Semantics

Push Capture waits? In the record / prelude On a HIT
Handler ctx.use(H)(value) yes yes in the record
Handler ctx.use(H)(promise) yes, bounded by ppr.captureTimeout; a miss still refuses the capture (unchanged) yes, baked in the record
ssr: false loader, settled value yes (already settled) yes in the record
ssr: false loader, promise, fast or slow or never no (changed) no (unchanged since #1054) arrives after hydration from the loader's run

Code change

settleCaptureRecord (src/rsc/shell-capture.ts) resolved freshHandleStore.getData() whole. It now resolves recordedHandleData(freshHandleStore, data) instead. recordedHandleData (src/handles/deferred-resolution.ts) is the helper resolvedHandleStream(..., recordedOnly) already used, exported; it applies HandleStore.getDataForSegment(id, true), the same tagged-push rule the record writer uses. No third copy of the rule; about 20 lines of non-test source; no stored format change.

Checks

  • The loader pin's runs bit is still set for a loader whose push was not waited for: the HIT unit test shows the body runs at g2 and delivers slow-deferred@g2 after hydration. The pin is written by the loader funnel, which the change does not touch.
  • A deferred push that settles within the budget is still not recorded: the existing /deferred-plain tests (Promise.resolve push, "is not in the shell") stay green.
  • A handler's deferred push that misses the budget still refuses the capture: new control a handler's deferred push slower than ppr.captureTimeout still refuses the capture, green before and after.

Test plan

  • serve-shell-request-push-ownership.rsc-test.tsx, group "the capture does not wait for a push its record leaves out": slow loader push does not refuse the capture and the shell/hydration have the settled row only; the HIT's run delivers the deferred row late; handler push control.
    • Red on origin/main (3 of the 4 new tests; the handler control passes): expect(hit.shellStatus).toBe("HIT") received "MISS".
  • e2e expectSlowDeferredPushDoesNotBlockCapture (shared body, tests/shared-e2e/src/index.ts), dev and (production) describes in both apps: packages/rangojs-router/e2e/shell-push-ownership.test.ts (route /shell-push/slow) and tests/cloudflare-basic/e2e/ppr-push-ownership.test.ts (route /ppr-push/slow). Green in all four. Red on origin/main (router test-app, dev): warmShellToHit never sees x-rango-shell: HIT (expect(res.headers()["x-rango-shell"]).toBe("HIT"), tests/shared-e2e/src/index.ts:2051), 20 s timeout.
  • Final-tree e2e (--no-deps): router test-app shell ppr semantic-matrix dev 131 passed (1 flaky pass on retry: ppr-header-guard hit a pnpm deps-status wrapper error, environment), production 133 passed; cloudflare-basic ppr production 85 passed, dev 86 passed with 1 failure (ppr-shell dev readiness: cold first request (#719) cold virgin route), which passes alone on the same tree. Not run on origin/main.
  • typecheck, test:unit:all, lint, format, check:e2e-bucketing, check:e2e-parity --strict, check:docs-api, check:bundle-guards.

Notes

  • Regenerated router.named-routes.gen.ts in both apps that gained a route (test-app, cloudflare-basic); no other app changed.

  • A route whose loader push never settles still hangs a document MISS (the MISS hydration data awaits it); the unit test therefore uses a slow push, not a never-settling one. Only the capture stopped waiting.

  • Follows fix(router): a document served from a PPR shell hydrates with the handle data its shell was rendered from (#1035) #1054.

  • Review: read by the reviewer directly given the size (about 20 lines of source: settleCaptureRecord waits on recordedHandleData, the filter the record writer and the prelude already share, now exported; no new copy of the rule). Second run on the final tree, after merging main: gate green (router 7566 unit, 516 RSC with 2 expected failures); --no-deps e2e: router app shell-push-ownership and shell-cache 70 passed in dev and 70 in production; cloudflare-basic ppr-push-ownership and ppr-capture-timeout 10 and 10.

  • Not changed, found while testing: a loader promise push that NEVER settles hangs a document MISS, because a normal document awaits every top-level pushed promise for its HTML (resolvedHandleStream, documented as "a hung handle promise blocks the full render like any unresolved await"). The capture no longer waits for it, but the MISS that triggers the capture still does.

ivogt added 2 commits October 4, 2026 15:23
… leaves out (#1035)

The capture waited for every deferred handle push before writing the record,
including promises pushed by ssr: false loaders, which the record drops and
the prelude has no slot for. A push slower than ppr.captureTimeout (or one
that never settles) refused the capture. It now waits on the recorded cut
only (recordedHandleData). Handler pushes are in the cut and still bound the
capture.
@pkg-pr-new

pkg-pr-new Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 7c79610

…ndered-push-wait

# Conflicts:
#	packages/rangojs-router/CHANGELOG.md
@ivogt
ivogt merged commit 5b7d02a into main Oct 4, 2026
43 checks passed
@ivogt
ivogt deleted the fix/capture-skip-unrendered-push-wait branch October 4, 2026 13:01
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