Skip to content

fix(router): an ssr: false document waits for pinged form tasks before its first flush (#1138) - #1139

Merged
ivogt merged 1 commit into
mainfrom
fix/1138-ssr-false-pinged-form
Oct 13, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/1138-ssr-false-pinged-form

Conversation

@ivogt

@ivogt ivogt commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

devcorner: dc-t46 · session dc-s45

An independent Opus review and /simplify found nothing blocking (dc-c194 on dc-t46). The one simplification it suggested is applied.

Closes #1138

Problem

ssr: false promises that a document render awaits the loader before its first flush ("data settled, no fallback paints"). A useActionState form under an inline <Suspense> broke that promise: its loader was ssr: false and 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:

Mode (fixture /client-urls-ssr-false-form, 20 no-JS loads against one server) main this PR
router test-app, dev (Node) 20/20, 20/20 lost 0/20 x3
router test-app, production (vite preview, Node) 20/20 x3 lost 0/20 x3
cloudflare-basic, dev (workerd) 20/20 x2 lost 0/20 x3
cloudflare-basic, production (workerd preview) 20/20 x3 lost 0/20 x3

"Lost" means the document had <!--$?-->, the fallback, or the form after <div hidden id="S:n">.

Why

  1. Fizz's useActionState binds 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.
  2. Encoding a bound reference for <form action> throws the Flight client's encodeFormData thenable: defaultEncodeFormAction in the vendored react-server-dom-webpack-client.edge.production.js:600-617. Fizz rethrows it (getCustomFormFields, :868-884) and parks the task on it (renderNode, :6298-6312).
  3. The thenable settles in microtasks. Once the request has left its opening status (startWork's timer, :7566), pingTask retries the task on setTimeout(0) (:4523-4534).
  4. renderHTML returned the stream as soon as renderToReadableStream resolved at shell-ready (src/ssr/index.tsx), and the consumer's first read beat that turn.

Confirmed three ways:

  • Trace in 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.
  • Control: an unbound action loses 0/20, and the third ping disappears.
  • Standalone Fizz repro, no Rango: with a bound action, 20/20 first flushes carry the fallback in both the dev and production React builds. One 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.

export default clientUrls(({ path, loader }) => [
  path("/item", Page, { name: "item" }, () => [
    loader(ItemLoader, { ssr: false }),
  ]),
]);
// Page: <Suspense fallback={<p>Reading…</p>}><Part /></Suspense>
// Part: const { data } = useLoader(ItemLoader); return <SaveForm id={data.id} />
// SaveForm: const [state, action] = useActionState(save, null); <form action={action}>…

Before (the test-app production build, trimmed):

<section data-testid="sff-page"><h1>Frame</h1><!--$?--><template id="B:0"></template><p data-testid="sff-fallback">Reading</p><!--/$--></section>
… <div hidden id="S:0"><form data-testid="sff-form" …>…</form></div> … $RC("B:0","S:0")

After:

<section data-testid="sff-page"><h1>Frame</h1><!--$--><form data-testid="sff-form" action="" encType="multipart/form-data" method="POST"><input type="hidden" name="$ACTION_REF_1"/>…<button type="submit" data-testid="sff-submit">Save</button></form><!--/$--></section>

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:

"use client";
import { Suspense, useActionState } from "react";
import { clientUrls, useLoader } from "@rangojs/router/client";
import { InviteLoader } from "./invite.loader";
import { acceptInvite } from "./actions"; // "use server"

function AcceptForm({ code }: { code: string }) {
  const [state, action] = useActionState(acceptInvite, null);
  return (
    <form action={action}>
      <input type="hidden" name="code" value={code} />
      <button>Accept</button>
      {state?.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

function Invite() {
  const { data } = useLoader(InviteLoader);
  return <AcceptForm code={data.code} />;
}

function InvitePage() {
  return (
    <main>
      <h1>You are invited</h1>
      <Suspense fallback={<p>Reading the invite…</p>}>
        <Invite />
      </Suspense>
    </main>
  );
}

export default clientUrls(({ path, loader }) => [
  path("/invite/:code", InvitePage, { name: "invite" }, () => [
    loader(InviteLoader, { ssr: false }),
  ]),
]);

Without JavaScript, the visitor now gets the form and can submit it: the e2e also submits the no-script form and sees the useActionState result rendered in place.

Semantics

Document First flush
No ssr: false loader on the matched chain Unchanged: returned at shell-ready, no turn. A loading() skeleton still leads the first flush (the #1120 scar; unit-pinned).
ssr: false loader, document whole at shell-ready (allReady settled) Unchanged: no macrotask (unit-pinned).
ssr: false loader, a boundary parked on a thenable that settles in microtasks (a useActionState form, a formAction button inside it, the form behind lazy()) Changed: up to three setTimeout(0) turns, stopping at allReady. The boundary is in place.
ssr: false loader, a boundary waiting on I/O (an unflagged loader, a streamed promise) Still streams its fallback, sent at most three turns later.
streamMode: "allReady" Unchanged: awaits allReady.
Navigations, actions' Flight responses, the Flight path Untouched: this is renderHTML only.
PPR shell capture / resume Untouched. A capture's pings are microtasks (trackedPostpones), and it already takes 16 hops.

Code change

packages/rangojs-router/src/ssr/index.tsx only. It reads the awaitBeforeFlush stamp that the progressiveChunkSize auto-raise already reads off the payload, so it builds on the existing ssr: false mechanism rather than adding a separate signal:

const SSR_FALSE_FIRST_FLUSH_TURNS = 3;

async function settleSsrFalseFirstFlush(allReady: Promise<void>): Promise<void> {
  let whole = false;
  const settle = (): void => {
    whole = true;
  };
  Promise.resolve(allReady).then(settle, settle);
  // A settled allReady's reaction was queued before this continuation's.
  await Promise.resolve();
  for (let turn = 0; !whole && turn < SSR_FALSE_FIRST_FLUSH_TURNS; turn++) {
    await macrotask();
  }
}

// renderHTML: the payload is awaited before render in every case
// (Fizz cannot emit <html> before it settles anyway).
const awaitsBeforeFlush = hasAwaitBeforeFlush(await payload);
const progressiveChunkSize =
  deps.progressiveChunkSize ??
  (awaitsBeforeFlush ? Number.MAX_SAFE_INTEGER : undefined);

// After the dev client-load settle:
if (streamMode === "allReady") {
  await htmlStream.allReady;
} else if (awaitsBeforeFlush) {
  await settleSsrFalseFirstFlush(htmlStream.allReady);
}

Why this design:

  • Turns, not wall-clock. A timer set after Fizz's ping fires after it, so each turn retries exactly one generation of pinged tasks.
  • Why the cap is 3. A useActionState form is one generation: the Flight client's boundCache makes the retry encode synchronously. A formAction button inside the form renders on that retry and is a second. The same form behind lazy() is a third. The unit tests pin each depth: cap 1 fails the nested-button test, and cap 2 fails the lazy() test.
  • Cost. It only applies to ssr: false documents that still have a pending boundary after the turns. Each turn is one setTimeout(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. setImmediate can't replace it, because the turn has to come after Fizz's own setTimeout ping.
    • On workerd production, the fixture's first chunk moved from a median of 2.73 ms to 2.98 ms; that fixture takes one turn.
    • On the cloudflare-basic mirror route (an ssr: false loader 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.
  • Client bytes: 0. This is SSR-only.

Alternatives considered:

Test plan

  • Unit, src/ssr/__tests__/ssr-handler.test.tsx "ssr: false first flush". Real react-dom/server.edge and real bound references from @vitejs/plugin-rsc/react/ssr, the module the SSR environment imports "use server" references from.
    • On main, 5 of 8 fail: the four form cases (fallback in the HTML) and the turns bound. With this PR, all 8 pass.
    • What they pin:
  • New e2e in both apps, a dev describe and a (production) describe each (body in tests/shared-e2e/src/ssr-false-form-scenario.ts):
    • the document's HTML has the form in place, 20 of 20;
    • a no-script page shows the form, 20 of 20 (javaScriptEnabled: false);
    • the no-script form submits and its result renders in place.
    • On main, all 12 fail (6 per app; 20 of 20 loads lost in the HTML test). With this PR: router test-app 6/6, cloudflare-basic 6/6.
  • Regression e2e, dev and production, 0 failed, 0 flaky:
    • router test-app, 315 passed (155 dev, 160 production): suspense-cases, held-boundary, loading-fn, error-boundary, client-urls*, stream-mode, suspense-stream, streamed-boundary-adoption, semantic-matrix.
    • cloudflare-basic, 314 passed (158 dev, 156 production): suspense-cases, held-boundary, client-urls* (ssr-signals included), streamed-handler-boundary, stream-suspense, streamed-boundary-adoption, stream-idle, ppr-shell, ppr-push-ownership, ppr-bake-lane-jsx.
  • Semantic matrix: 31 dev + 34 production, green (inside the router run above).
  • vite-rsc-demo e2e, full suite: 309 passed (164 dev, 145 production), 0 retries.
  • Gate:
    • 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.
    • oxlint --deny-warnings: 0 warnings, 0 errors. oxfmt: clean.
    • Guards green: 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.tsx is reached only through the ./ssr export, so no client bytes change.

No userland @rangojs/router/testing test. No primitive reaches Fizz: serveShellRequest stubs the HTML step because react-dom/server refuses to load under the react-server condition it runs in, and renderRoute({ hydrate }) uses renderToString outside createSSRHandler. The unit tests drive the public createSSRHandler (@rangojs/router/ssr) with the production renderer instead.

Notes

  • The reporter saw 0 of 10 lost in production. The fixture loses 20 of 20 there, because its boundary content renders after Fizz's first turn, where pings become timers. Whether a page loses depends on when its boundary renders. Nothing guaranteed the reporter's 0 of 10.
  • A deeper chain than three generations still streams its fallback, as before.
  • The one wait this adds (dc-d141). The router adds a bounded wait before the first flush, but only for documents that opted in with 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.
  • The first (cold) document on a fresh production server can still ship the fallback while a client module loads from disk. That predates this PR and the client-urls skill documents it. The e2e skips that first load.
  • An explicit progressiveChunkSize now also awaits the payload before render. A payload rejection there goes through the same catch and reportRenderError path the default config already takes, instead of Fizz's onError.
  • Docs updated: docs/client-urls.md (the no-JS section), docs/internal/feature-map.md (the ssr: false bullet), the client-urls skill's "Without JavaScript" paragraph, and the CHANGELOG.

@pkg-pr-new

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

Copy link
Copy Markdown

Open in StackBlitz

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

commit: b663735

…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
ivogt force-pushed the fix/1138-ssr-false-pinged-form branch from 4ce64fd to b663735 Compare October 13, 2026 06:47
@ivogt
ivogt marked this pull request as ready for review October 13, 2026 06:47
@ivogt
ivogt merged commit 24ab414 into main Oct 13, 2026
74 of 78 checks passed
@ivogt
ivogt deleted the fix/1138-ssr-false-pinged-form branch October 13, 2026 07:08
ivogt added a commit that referenced this pull request Oct 13, 2026
0.24.3 was published from release/0.24.x with #1137 and #1139 only. main
takes the version and moves those two entries under their own heading; the
rest of Unreleased stays for the next release.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant