Skip to content

feat: seed SSR action results into hydration so async render does not re-fetch (#472)#486

Merged
vivek7405 merged 11 commits into
mainfrom
feat/seed-ssr-action-hydration
Jun 13, 2026
Merged

feat: seed SSR action results into hydration so async render does not re-fetch (#472)#486
vivek7405 merged 11 commits into
mainfrom
feat/seed-ssr-action-hydration

Conversation

@vivek7405

Copy link
Copy Markdown
Collaborator

Problem

With async render shipped (#469/#470), a SHIPPING async component re-runs async render() on hydration, which re-issues the action over RPC. Stale-while-revalidate (#470) hides the flicker, but the redundant round-trip still costs one RPC per async component on first load.

Approach

Capture each 'use server' action result invoked during a (non-streamed) SSR render and serialize it into the page as one <script type="application/json" id="__webjs-seeds"> block. The generated client RPC stub reads that seed on its FIRST call and resolves synchronously (no network); a later refetch / arg-change misses (consume-once) and goes to RPC as normal.

The capture is a transparent SERVER-SIDE facade over the 'use server' module, installed via Node's synchronous module.registerHooks: the facade re-exports each function wrapped in a Proxy that records (file, fn, args) -> result into an ambient AsyncLocalStorage collector, which is active ONLY around the SSR render (collectSeeds). The RPC endpoint path runs with no collector, so the Proxy is a pure passthrough there.

The framework promises stay intact

  • No build step. The capture is runtime-only (a load hook + a Proxy); no bundler, no webjs build.
  • What you see in the browser source tab is what you wrote. The on-disk files are byte-unchanged, and the browser fetches the same RPC stub shape as before (it just consults the seed first). The facade is server-side and never reaches the browser, symmetric with how the RPC stub already replaces the action on the client.
  • Progressive enhancement. SSR still bakes the resolved data into the first paint (JS-off reads it); the seed only removes the redundant hydration fetch.

Safety

Fail-open by construction: a key HIT returns the exact SSR value (correct by construction), a key MISS degrades to a normal RPC (never wrong data, only a missed optimization). Keyed by action-hash + fn + serialized args (the exact key the stub computes). Default on; opt out with "webjs": { "seed": false } or WEBJS_SEED=0, which removes the hook entirely (module loading byte-identical to before). Streamed <webjs-suspense> regions are not seeded (their data resolves after the first byte), so they keep the stale-while-revalidate refetch. Soft navigation also carries the seed (the router ingests the incoming page's payload before its components hydrate).

Tests

  • Unit (server): export-name extraction, the __seedWrap Proxy (record inside a collector, passthrough outside, non-function passthrough, expose()/validateInput() metadata survival), collectSeeds, key determinism (server key == stub lookup key), buildSeedScript (empty, HTML-escape, round-trip through parse).
  • Load-hook integration (isolated process): the facade actually intercepts a real import, gates on 'use server', passes non-functions through.
  • SSR integration (createRequestHandler): the seed payload is present and keyed exactly as the stub looks it up, the action ran once per component, rich types intact; plus the WEBJS_SEED=0 counterfactual (no payload, data still in first paint).
  • Unit (client): scanSeeds ingests the page block + per-element carriers, takeSeed is consume-once and misses on a different file/fn/args key.
  • Switch resolution: WEBJS_SEED env over webjs.seed over default-on.
  • e2e (real browser, network probe): NO /__webjs/action/ RPC fires on hydration of <seeded-user>, a prop bump to an unseeded id DOES fetch, and a soft navigation to /seeded also fires no RPC. (The issue's headline acceptance.)

npm test is green (2428 node tests). Browser/e2e run with WEBJS_E2E=1 (both new #472 cases pass).

Dogfood (all four apps)

  • examples/blog: full e2e suite green (83 tests), including the differential-elision parity guard (seeding does not alter elision output).
  • website / docs / packages/ui/packages/website: boot through createRequestHandler in prod mode, GET real routes, status < 400, no broken modulepreload hints.

Docs

AGENTS.md + agent-docs/components.md updated (the #472 hydration-seed optimization is now shipped, not deferred); the user-facing docs/app/docs/data-fetching page gained a seeding section; the webjs.seed config key added to the schema, the WebjsConfig type, and the config drift test.

Closes #472

t added 11 commits June 13, 2026 12:57
The server module installs a synchronous module.registerHooks facade that
wraps each 'use server' export in a Proxy recording (file, fn, args) ->
result into an ambient AsyncLocalStorage collector, active only around the
SSR render. The client module consumes seeds (consume-once, keyed
hash/fn/stringify(args)) so the generated RPC stub can resolve an async
component's first render without a hydration round-trip. Wired in later
commits; inert until then.
dev.js installs the seed load hook at boot when enabled (readSeedEnabled:
WEBJS_SEED env + webjs.seed config, default on). ssr.js runs the
non-streamed render inside the ambient collector and appends the seed
<script> to the body. The generated RPC stub reads takeSeed before the
fetch, so an async-render component's first client call resolves from the
seed instead of a hydration round-trip. Adds the webjs.seed config key to
the schema, the WebjsConfig type, and the drift test's KNOWN_KEYS.
Unit (export extraction, __seedWrap record/passthrough/non-fn/expose-meta,
collectSeeds, key determinism, buildSeedScript escape+roundtrip), the
load-hook facade integration (isolated process), and SSR integration through
createRequestHandler asserting the seed payload key matches the stub lookup,
the action ran once per component, plus the WEBJS_SEED=0 counterfactual.
scanSeeds ingests the page-level JSON block and per-element carriers;
takeSeed is consume-once and misses on a different file/fn/args key (RPC
fallback); first-write-wins on duplicate keys; never throws without a DOM.
Adds a shipping <seeded-user> whose async render() awaits the getSeedUser
'use server' action, the /seeded page, and an e2e asserting NO /__webjs/action/
RPC fires on hydration (the SSR result was seeded) while a prop bump to an
unseeded id DOES fetch. The issue's headline acceptance, in a real browser.
Updates the async-render notes in AGENTS.md + agent-docs/components.md
(the #472 hydration-seed optimization is now shipped, not deferred) and
adds a seeding section to the user-facing data-fetching docs page,
covering the no-build/no-source-transform capture, consume-once keying,
the webjs.seed / WEBJS_SEED opt-out, and the streamed-region exclusion.
applySwap scans the incoming navigation document for the seed payload
before its components hydrate, so a soft-navigated async component
resolves from the seed (no action RPC) just like a full load. Scanning the
detached parse also strips the seed carrier so it never lands in the live
DOM. Adds a soft-nav e2e network probe alongside the initial-load one.
Adds action-seed.js (server) and action-seed-client.js (core) to the
module maps, notes the seed-aware RPC stub on the actions.js row, lists
readSeedEnabled in the config-block reader set + schema comment, adds the
seed-hook install to the eager-boot-work enumeration, and registers the
new test/seed folders.
Adds a 'Seeded' nav link, explanatory page copy, and a blog AGENTS note so
the SSR action-seeding fixture is a discoverable demo, not just a test prop.
@vivek7405 vivek7405 merged commit 86bd5a5 into main Jun 13, 2026
7 checks passed
@vivek7405 vivek7405 deleted the feat/seed-ssr-action-hydration branch June 13, 2026 10:12
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.

feat: seed SSR action results into hydration so async render does not re-fetch (follow-up to #469)

1 participant