Repository navigation
fix(router): dev documents render a loader's client component in place; clientUrls action-failure contract pinned and documented (#1106) - #1120
Merged
Conversation
…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.
commit: |
…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
This was referenced Oct 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1106.
Problem
skills/client-urls/SKILL.mdhad gaps that led an app's port toclientUrls()to the wrong conclusions (#1106). Two of them hid behaviour nothing pinned:<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.What changes
1. Dev: a document renders a loader's client component in place (
src/ssr/index.tsx, dev only)@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.trackDocumentClientLoads(decode)collects, per document (AsyncLocalStorage), the client-component loads the Flight decode requests.renderHTMLwaits for them before the first flush, capped at 5 s.process.env.NODE_ENV, which the build folds, so the hold is compiled out of production SSR bundles. Builds are unchanged.2. A loader that fails on its re-run after an action: pinned and documented
The behaviour is the same for
clientUrls()groups andurls()routes, excepturls()withloading():await action()<form action>urls()route withoutloading()urls()route withloading()errorBoundary()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(), apprbake 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)revalidate().completed, and the bridge callshandle.fail(error), souseActionreports the rejection.4. Docs for the 0.23.0 model
A new section, "After an action: what the server decides", covers:
revalidate(), which seesactionResultandformData;keepClientCache();It also notes:
clientUrlsis mentioned infeatures/caching.mdx, the loader skill and the server-actions skill.Test plan
<Suspense>): red before the fix ("Received: hidden"), green after; production green before and after;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.action-failure, dev and(production), router test-app and cloudflare-basic, with the scenario intests/shared-e2e/src/action-failure-scenario.ts. It covers group,urls(),urls()withloading(), group withloading(), anderrorBoundary(), plus form state surviving revalidation. There's also a userland rsc test throughserveShellRequest({ action }).event-controller.test.tsandserver-action-bridge.test.tswere red before the fix;stock:3(stock:4before).stale: truerows inclient-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).Notes
errorBoundary()e2e variant declares a dev Suspense-audit I5 drift warning. It comes from a pre-existing bug,StreamedLoaderErrorBoundarydropping its wrapper when the fallback renders (segment-system.tsx:863), which is tracked separately.