feat: seed SSR action results into hydration so async render does not re-fetch (#472)#486
Merged
Conversation
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.
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.
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 synchronousmodule.registerHooks: the facade re-exports each function wrapped in aProxythat records(file, fn, args) -> resultinto an ambientAsyncLocalStoragecollector, 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
webjs build.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 }orWEBJS_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
__seedWrapProxy (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 throughparse).import, gates on'use server', passes non-functions through.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 theWEBJS_SEED=0counterfactual (no payload, data still in first paint).scanSeedsingests the page block + per-element carriers,takeSeedis consume-once and misses on a different file/fn/args key.WEBJS_SEEDenv overwebjs.seedover default-on./__webjs/action/RPC fires on hydration of<seeded-user>, a prop bump to an unseeded id DOES fetch, and a soft navigation to/seededalso fires no RPC. (The issue's headline acceptance.)npm testis green (2428 node tests). Browser/e2e run withWEBJS_E2E=1(both new#472cases 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 throughcreateRequestHandlerin prod mode, GET real routes, status < 400, no broken modulepreload hints.Docs
AGENTS.md+agent-docs/components.mdupdated (the #472 hydration-seed optimization is now shipped, not deferred); the user-facingdocs/app/docs/data-fetchingpage gained a seeding section; thewebjs.seedconfig key added to the schema, theWebjsConfigtype, and the config drift test.Closes #472