Skip to content

fix(router): dev documents render a loader's client component in place; clientUrls action-failure contract pinned and documented (#1106) - #1120

Merged
ivogt merged 5 commits into
mainfrom
fix/client-urls-skill-1106
Oct 12, 2026
Merged

ivogt merged 5 commits into
mainfrom
fix/client-urls-skill-1106

Conversation

@ivogt

@ivogt ivogt commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator

Closes #1106.

Problem

skills/client-urls/SKILL.md had gaps that led an app's port to clientUrls() to the wrong conclusions (#1106). Two of them hid behaviour nothing pinned:

  • A no-JS document in dev showed only the <Suspense> fallback when a loader's answer drew a client component under an inline <Suspense>. Production rendered it whole, so a page passed no-script in production and failed it in dev.
  • A loader that throws on its re-run after an action ended in ways no test or doc described.

What changes

1. Dev: a document renders a loader's client component in place (src/ssr/index.tsx, dev only)

  • Cause: in dev, plugin-rsc tags client reference ids with a fresh cache tag on every render (@vitejs/plugin-rsc/dist/core/rsc.js:81). Its SSR loader cache is keyed by the tagged id (core/ssr.js:8), so every reference reloads through the module runner, which is an RPC per import on workerd. The component resolves after the shell flushed, so the fallback streams. Without JS, it's never swapped in.
  • Fix: trackDocumentClientLoads(decode) collects, per document (AsyncLocalStorage), the client-component loads the Flight decode requests. renderHTML waits for them before the first flush, capped at 5 s.
  • Build output: gated on process.env.NODE_ENV, which the build folds, so the hold is compiled out of production SSR bundles. Builds are unchanged.
  • Upstream: the root cause belongs in plugin-rsc: a cache tag that changes only on HMR would make the wait a no-op in steady state.

2. A loader that fails on its re-run after an action: pinned and documented

The behaviour is the same for clientUrls() groups and urls() routes, except urls() with loading():

Route await action() <form action>
A group route, or a urls() route without loading() rejects with the loader's error (the action ran; its result is lost); the page keeps its old data and state the nearest error boundary replaces the page (the root boundary if the app has none)
A urls() route with loading() resolves; the render then throws to the nearest error boundary same
Any route under errorBoundary() its fallback replaces the route same

The docs recommend returning a transient failure as data, with an example and a caching caveat: a returned failure is an ordinary value, so cache(), a ppr bake or a prerender stores it, while a thrown error is never stored. The skill, docs/client-urls.md, the docs-site guide and both server-actions docs are updated. Whether the action contract itself should change (resolve with the action's result and report the loader error separately) is a separate decision, not made here.

3. A rejected action finishes its action (event-controller.ts, server-action-bridge.ts)

  • Problem: an action that rejected never marked its tracking entry complete. For about 100 ms, the next action saw it as concurrent and added a consolidation refetch. That refetch left out the failed action's changed segments, so their loaders re-ran as new segments and skipped revalidate().
  • Fix: the dispose path sets completed, and the bridge calls handle.fail(error), so useAction reports the rejection.

4. Docs for the 0.23.0 model

A new section, "After an action: what the server decides", covers:

  • the action and its result;
  • each loader's own server-side revalidate(), which sees actionResult and formData;
  • keepClientCache();
  • a loader failing on its re-run.

It also notes:

  • form state survives an action's revalidation (pinned by a test);
  • clientUrls is mentioned in features/caching.mdx, the loader skill and the server-actions skill.

Test plan

  • Dev hold:
    • cloudflare-basic dev e2e (no-JS document with a loader's client component under <Suspense>): red before the fix ("Received: hidden"), green after; production green before and after;
    • unit ssr-handler.test.tsx: the first flush waits for a requested load; another render's load doesn't hold it; loads in the turns after a settle are waited for; the 5 s cap (fake timers); production installs no hold.
    • Each of the three surviving mutations now fails one test.
    • Only the cloudflare-basic dev e2e guards the real fix, since Node dev only misses on a cold module.
  • Action failure: e2e action-failure, dev and (production), router test-app and cloudflare-basic, with the scenario in tests/shared-e2e/src/action-failure-scenario.ts. It covers group, urls(), urls() with loading(), group with loading(), and errorBoundary(), plus form state surviving revalidation. There's also a userland rsc test through serveShellRequest({ action }).
  • Rejected-action fix:
    • event-controller.test.ts and server-action-bridge.test.ts were red before the fix;
    • the e2e asserts the exact stock:3 (stock:4 before).
  • Forced-true and stale: true rows in client-urls-revalidate.rsc-test.tsx.
  • pnpm run typecheck, pnpm run test:unit:all, pnpm run lint, pnpm run format, check:e2e-bucketing, check-e2e-parity --strict, check:docs-api, check:skills, check:doc-links, check:suspense-contract, check:no-external-store, test:hmr-routes-local (21/21), check-bundle-guards (router chunk 49,493 B of 49,536 B).
  • Opus review plus /simplify; the review round is applied.

Notes

  • The errorBoundary() e2e variant declares a dev Suspense-audit I5 drift warning. It comes from a pre-existing bug, StreamedLoaderErrorBoundary dropping its wrapper when the fallback renders (segment-system.tsx:863), which is tracked separately.
  • Bundle headroom is small, and together with another pending PR the chunk will be within a few bytes of the limit.

ivogt added 3 commits October 12, 2026 12:04
…e; clientUrls action-failure contract pinned (#1106)

Dev SSR: plugin-rsc tags client reference ids with a per-render cache tag
in dev, so its SSR loader memo missed on every request and each reference
loaded through the module runner again (an RPC per import on workerd). A
client component a loader's answer draws under an inline <Suspense>
resolved after the shell flushed, so a no-script dev document showed only
the fallback while a build rendered it in place. renderHTML now holds a dev
document's first flush until the client-reference loads its Flight decode
requested have settled (settleDocumentClientLoads, attributed per document
through an AsyncLocalStorage, bounded at 5 s). Builds are unchanged.

clientUrls docs: state what the server decides after an action, that a
form's React state survives the revalidation, and what a loader that
throws on its rerun does (the call rejects and nothing of that
revalidation commits; a form action reaches the nearest React error
boundary; an errorBoundary() around the mount replaces the route), with
the return-the-failure-as-data pattern. Mention clientUrls in the loader
and server-actions skills and the caching docs page.

Tests: shared e2e scenarios (action-failure, loader-element) in the
router test-app and cloudflare-basic, dev and production; a
serveShellRequest({ action }) userland test of the action response, plus
forced-true and stale revalidate() rows; SSR unit tests for the dev hold.
…action runs no consolidation refetch

When the revalidation render of an action threw (a loader failing on its
rerun, on a route without loading()), the bridge disposed the action's
handle without complete()/fail(). The entry stayed uncompleted for its
100 ms settle window, so an action started in it counted as concurrent and
ran a consolidation refetch that left out the failed action's revalidated
segments: their loaders reran as new segments whatever their revalidate()
answered (#675 class). useAction also reported no error.

The dispose path now marks the entry completed, and the bridge's catch
records what the call rejects with through handle.fail(), so useAction
reports it as the action's error.

Red before green: event-controller.test.ts "action starting during a
disposed action's settle window is not concurrent" and
server-action-bridge.test.ts "records the rejection as the action's error".
… add the cache caveat; tidy the dev SSR hold

A urls() route with loading() does not reject the action call when a loader
throws on its rerun: the action lane hands the settled data to
LoaderBoundary, which decodes it while React renders, so the call resolves
and the read throws to the nearest error boundary. A group route never takes
that branch. The skill, docs/client-urls.md, the docs-site guide and the
server-actions skill and page now scope the first row and add a row for a
urls() route with loading(), pinned by new dev + production e2e cases in
both apps (/throw-loading in the group and its urls() twin). Returning a
failure as data now carries a caveat: cache(), a ppr bake of an ssr:false
loader, and a prerender store it like any value; a thrown error is never
stored. ssr: false is described as guaranteeing the loader's data, not a
client module's first load in a build. The internal doc drops the
consolidation note (fixed in the previous commit) and cites
server-action-bridge.ts:848.

The I5 drift allowance in the bounded e2e case now names its cause:
segment-system.tsx:863 plants the error fallback in place of the
StreamedLoaderErrorBoundary-wrapped content, against suspense-contract.md:448.

SSR: trackDocumentClientLoads owns the dev decode's client-load set, and
settleDocumentClientLoads races the loads against the deadline instead of
an expired flag. The dev gate keeps process.env.NODE_ENV, with the reason in
a comment. New unit tests fail when the two task yields are removed, when
the 5 s cap is removed, and when the gate is forced on in production.
@pkg-pr-new

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

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 51d7b85

ivogt added 2 commits October 12, 2026 13:13
…g when the shell is ready

The hold added in this branch looped until no new client-reference load
appeared, and always yielded two tasks once any load had been recorded,
even when every load had settled before the shell was ready. On a dev
document whose content arrives right after the shell (a 200 ms handler
under loading(), a streaming error), those turns let the content into the
first flush, so the loading() skeleton and the streamed error's fallback
stopped streaming in dev: e2e/loading-fn.test.ts:39 and :49 and
e2e/error-boundary.test.ts:123 failed on CI (dev shard), red locally on
892663b.

Now the document's set holds only pending loads, and settleDocumentClientLoads
takes a snapshot when the shell is ready: nothing pending means no wait; a
pending load (its row arrived, so its data is in hand) is awaited, then the
two turns Fizz retries in, bounded at 5 s; a load a later row requests is
not waited for. The #1106 no-JS case stays green in cloudflare-basic dev (red
with the hold disabled).

Unit tests replace the cascade test: a load requested after the shell is
ready is not waited for, nothing pending adds no wait (both red on the
892663b source), and a pending load gets the turn Fizz retries in (fails
when the turns are removed).
…-1106

# Conflicts:
#	packages/rangojs-router/CHANGELOG.md
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.

client-urls skill: gaps on actionResult, what the server controls, loader failure after an action, form state, actions in loader answers

1 participant