Repository navigation
fix(router): an ssr: false document waits for pinged form tasks before its first flush (#1138) - #1139
Merged
Merged
Conversation
commit: |
…e form under Suspense in place (#1138) A no-script document shipped the boundary's fallback and left the form in <div hidden id="S:n">, although every loader the boundary read was ssr: false and settled. Fizz's useActionState binds its state onto the action; encoding that bound server reference for <form action> throws the Flight client's encodeFormData thenable once, and Fizz retries the task on a setTimeout(0) turn once the request has left its opening status. renderHTML returned the stream at shell-ready, before that turn. On the fixture it lost 20 of 20 loads in dev and production, on Node and workerd. renderHTML now gives Fizz up to three setTimeout(0) turns after shell-ready, stopping at allReady, when the payload carries awaitBeforeFlush (the stamp the progressiveChunkSize auto-raise already reads). A document already whole takes no turn; documents without an ssr: false loader, navigations, streamMode "allReady" and the PPR capture/resume paths are unchanged. The cap is pinned by unit tests: a form is one generation, a formAction button inside it two, the same form behind lazy() three. Tests: ssr-handler unit cases with real react-dom/server.edge and bound references from @vitejs/plugin-rsc/react/ssr (5 of 8 red on main), and a shared no-script e2e scenario in the router test-app and cloudflare-basic, dev and production (12 of 12 red on main). Closes #1138
ivogt
force-pushed
the
fix/1138-ssr-false-pinged-form
branch
from
October 13, 2026 06:47
4ce64fd to
b663735
Compare
ivogt
marked this pull request as ready for review
October 13, 2026 06:47
ivogt
added a commit
that referenced
this pull request
Oct 13, 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.
devcorner: dc-t46 · session dc-s45
Closes #1138
Problem
ssr: falsepromises that a document render awaits the loader before its first flush ("data settled, no fallback paints"). AuseActionStateform under an inline<Suspense>broke that promise: its loader wasssr: falseand settled, yet a browser without JavaScript got the boundary's fallback ("Reading…"). The form arrived at the end of the stream in<div hidden id="S:0">, revealed by a script that never runs. On the fixture this happened on every load:/client-urls-ssr-false-form, 20 no-JS loads against one server)vite preview, Node)"Lost" means the document had
<!--$?-->, the fallback, or the form after<div hidden id="S:n">.Why
useActionStatebinds its state onto the action (action.bind(null, initialState),react-dom-server.edge.production.js:3826, react-dom 19.3.0). That makes the action a bound server reference.<form action>throws the Flight client'sencodeFormDatathenable:defaultEncodeFormActionin the vendoredreact-server-dom-webpack-client.edge.production.js:600-617. Fizz rethrows it (getCustomFormFields,:868-884) and parks the task on it (renderNode,:6298-6312).:7566),pingTaskretries the task onsetTimeout(0)(:4523-4534).renderHTMLreturned the stream as soon asrenderToReadableStreamresolved at shell-ready (src/ssr/index.tsx), and the consumer's first read beat that turn.Confirmed three ways:
renderHTML: the ping timer was set at 6.45 ms, the shell was ready at 6.51 ms, the first chunk was read at 6.70 ms, and the retry ran at 8.04 ms.setTimeout(0)before the first read brings that to 0/20, and an unbound action is 0/20.Before / after
The same route, unchanged. Only the no-script HTML differs.
Before (the test-app production build, trimmed):
After:
End-to-end consumer example
A sign-up page that must work without JavaScript, whose loader is flagged so the page's data is in the HTML:
Without JavaScript, the visitor now gets the form and can submit it: the e2e also submits the no-script form and sees the
useActionStateresult rendered in place.Semantics
ssr: falseloader on the matched chainloading()skeleton still leads the first flush (the #1120 scar; unit-pinned).ssr: falseloader, document whole at shell-ready (allReadysettled)ssr: falseloader, a boundary parked on a thenable that settles in microtasks (auseActionStateform, aformActionbutton inside it, the form behindlazy())setTimeout(0)turns, stopping atallReady. The boundary is in place.ssr: falseloader, a boundary waiting on I/O (an unflagged loader, a streamed promise)streamMode: "allReady"allReady.renderHTMLonly.trackedPostpones), and it already takes 16 hops.Code change
packages/rangojs-router/src/ssr/index.tsxonly. It reads theawaitBeforeFlushstamp that theprogressiveChunkSizeauto-raise already reads off the payload, so it builds on the existingssr: falsemechanism rather than adding a separate signal:Why this design:
useActionStateform is one generation: the Flight client'sboundCachemakes the retry encode synchronously. AformActionbutton inside the form renders on that retry and is a second. The same form behindlazy()is a third. The unit tests pin each depth: cap 1 fails the nested-button test, and cap 2 fails thelazy()test.ssr: falsedocuments that still have a pending boundary after the turns. Each turn is onesetTimeout(0). On Node that is a full event-loop iteration (at least 1 ms, its clamp), so under load a document that really streams pays up to 3 iterations of time to first byte.setImmediatecan't replace it, because the turn has to come after Fizz's ownsetTimeoutping.ssr: falseloader plus unflagged chrome loaders still streaming), the shift was inside the 300 ms loader's jitter: main medians 308.6/315.6 ms against 312.9/316.6 ms.Alternatives considered:
allReadyforssr: falsedocuments. Rejected: it would hold unflagged siblings, but the contract is per loader.encodeFormActionthat never suspends. Rejected: the bound args are a promise with no synchronous value, and it would change the progressive-enhancement wire format.Test plan
src/ssr/__tests__/ssr-handler.test.tsx"ssr: false first flush". Realreact-dom/server.edgeand real bound references from@vitejs/plugin-rsc/react/ssr, the module the SSR environment imports"use server"references from.progressiveChunkSize;formActionbutton is whole;lazy()form with the nested button is whole;ssr: falseis returned before the next turn, and its skeleton is in the first chunk;streamMode: "allReady"is unchanged.(production)describe each (body intests/shared-e2e/src/ssr-false-form-scenario.ts):javaScriptEnabled: false);turbo run typecheck --env-mode=loose: 16/16.test:unit:all: router 8784 passed (5 skipped, 6 todo); router rsc 846 passed (2 expected fail); mini, cloudflare-basic, vite-rsc-demo and shared-e2e green.--deny-warnings: 0 warnings, 0 errors. oxfmt: clean.check:e2e-bucketing,check:e2e-parity --strict,check:route-gen,check:suspense-contract,check:docs-api,check:doc-links,check:skills,check:skills-catalog,check:no-external-store.check:bundle-guards: green. The cloudflare-basic router chunk is 49387 B gzip, under its 49536 B cap.src/ssr/index.tsxis reached only through the./ssrexport, so no client bytes change.No userland
@rangojs/router/testingtest. No primitive reaches Fizz:serveShellRequeststubs the HTML step becausereact-dom/serverrefuses to load under thereact-servercondition it runs in, andrenderRoute({ hydrate })usesrenderToStringoutsidecreateSSRHandler. The unit tests drive the publiccreateSSRHandler(@rangojs/router/ssr) with the production renderer instead.Notes
ssr: false, whose contract is already a wait before the first flush. It does not steer React's scheduling. The client-urls skill: gaps on actionResult, what the server controls, loader failure after an action, form state, actions in loader answers #1106 dev hold is the precedent.progressiveChunkSizenow also awaits the payload before render. A payload rejection there goes through the same catch andreportRenderErrorpath the default config already takes, instead of Fizz'sonError.docs/client-urls.md(the no-JS section),docs/internal/feature-map.md(thessr: falsebullet), theclient-urlsskill's "Without JavaScript" paragraph, and the CHANGELOG.