You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Line anchors in this body were verified against HEAD ddfc5547. Anchors that had drifted, or claims that turned out to be wrong, are corrected inline and flagged (corrected).
Problem
SSR action seeding (#472) is the safest of the three inference-based subsystems, and its real risk is misread. A miss degrades to a normal RPC. The whole feature is fail-open by construction (packages/server/src/action-seed.js L52-57, restated at L468).
The actual production risk is a silent performance regression. Because a miss is indistinguishable from a hit from the outside, a refactor that breaks seeding for an entire app produces no error, no warning, and no log line. The app just quietly re-issues one RPC per async component on every first load, which is exactly what the feature exists to remove. Three real ways it breaks silently, all fail-open by design:
extractExportNames (L365) and buildSeedFacade (L551) are deliberately conservative. A name the extractor misses stays a plain value binding, so the export is never wrapped and never seeded. That is a MISS, not a crash.
The key is hashFile(file)/fn/stringify(args) (L189-197), computed independently on both sides. Any drift in either half makes every lookup miss.
There is a second, narrower gap. An action that is not deterministic for a given argument list is the one shape where a hit can surprise the author, and nothing detects it today. recordSeed does collector.set(key, value) (L197) with no check, so the LAST result for a key wins while the FIRST component already painted the first result.
What was verified, and what was wrong in the previous statement of this issue
Every anchor the previous body cited is correct at ddfc5547: seedingEnabled() L118, identityHookInstalled() L128, actionIdentityOf() L139, actionFileHash() L168, recordSeed L189 with the keying at L196-197, __actionWrap L225, the Proxy apply trap L257-273 (async record at L265, sync at L273), extractExportNames L365, buildSeedFacade L551, registerActionHooks L605, collectSeeds L641, buildSeedScript L657, and on the client the seeds Map L26, SEED_MISS L29, scanSeeds L42. Two CLAIMS in that body do not survive contact with the code.
(corrected) Per-element data-webjs-seed carriers have no producer.scanSeeds (L42-53) reads two carriers, the page-level #__webjs-seeds block and per-element [data-webjs-seed] attributes. A repo-wide grep finds data-webjs-seed only in core, its .d.ts, its unit test, and two doc lines. The server never emits one. The module header calling it "for streamed regions" is aspirational: ssr.js L291 emits the seed block only when suspenseCtx.pending.length === 0, so a streamed page emits no seeds at all, not per-element ones. Counting must still cover both carriers, because the client reads both, but nothing here may claim streamed regions are seeded today.
(corrected) The correctness boundary is not unconditionally true. Measured, not reasoned. The client seed store is module-global and ingest (L63-76) is FIRST-WRITE-WINS (if (!seeds.has(k)) seeds.set(k, obj[k])). A takeSeed hit DELETES the key (L97), so the stated rationale ("an already-consumed seed is never clobbered") describes a case that cannot occur. What the rule actually does is keep an unconsumed seed from an earlier render in preference to a fresher one for the same key. Reproduced against the real module:
render 1 seeds h/getUser/[1] = 'render-1-value' (nothing consumes it: the seeding component elided)
render 2 (soft nav) seeds the same key = 'render-2-value'
a shipping component on page 2 calls getUser(1) -> 'render-1-value'
So today a hit can hand a component a value from a render that is no longer on screen. It is reachable through three paths that all funnel into applySwap (packages/core/src/router-client.js L2966 scanSeeds(doc)): a soft navigation, the background revalidation after one, and a back/forward snapshot restore (L1368, which parses the cached HTML and applies it through the same path). The snapshot can still carry a seed block because the initial scan is LAZY (first takeSeed, L90-93), so a page whose seeding components all elided never scans, never removes the block, and snapshots it intact.
The rest of the boundary claim holds and was checked: a same-render hit returns the exact object the awaiting async render() received (seedProxy L264-267 records the resolved value and returns it unchanged); a second consumer of the same key simply misses and re-fetches; a streamed result is skipped outright (L193); and an HTML-cached page (#241) carries its seed block inside the cached bytes, so the seed is exactly as fresh as the HTML it rode in on.
Design / approach
Six decisions. None changes the wire format, the default, or the fail-open contract.
1. Make the boundary claim true before documenting it: ingest becomes last-write-wins
Flip ingest to overwrite. The newest render is always the one whose paint is on screen, on every path that reaches scanSeeds (initial load, soft nav, revalidation, snapshot restore), so last-write-wins is the rule that makes "a hit is the value the paint you are looking at used" true rather than nearly true. First-write-wins protects nothing (its stated case is unreachable) and is the only reason the sentence needed a caveat.
One line, plus an inverted existing test. WebJs has no users, so a clean change beats documenting a stale-data path in a feature whose selling point is "never wrong data" (the repo rule is to prefer a clean break over a back-compat shim).
Rejected: keep first-write-wins and document the caveat. The docs deliverable of this issue is the correctness sentence, and shipping that sentence with a stale-data asterisk is worse than deleting the asterisk. Rejected: clear the whole store on every navigation. An in-flight async render() from the outgoing page can still be about to consume a legitimate seed, and dropping it costs a round-trip for no correctness gain.
2. Server counts ride a dev-only response header, folded into the existing access log
Emit X-Webjs-Seed from ssr.js, and fold its value into the ONE structured access-log line dev.js already writes per request (L1319-1325). No new console line, no new log volume.
Prior art: Next.js reports cache state exactly this way, an x-nextjs-cache header carrying HIT / MISS / STALE / REVALIDATED (~/Documents/Projects/frameworks/next.js/packages/next/src/build/templates/app-page-runtime.ts L1670-1691, and again in packages/next/src/server/route-modules/pages/pages-handler.ts L487). A header is the right carrier because it is per response, needs no client, survives curl, and is trivial to assert in a test. WebJs already ships six X-Webjs-* headers, so neither the name nor the shape needs inventing.
Streamed render (a Suspense / <webjs-suspense> boundary)
collected=<m>, emitted=0, streamed
collected is collector.size and emitted is the number of keys that actually reached the page. They differ exactly when buildSeedScript's stringify threw and dropped the whole block (L670-672), which is today a completely invisible failure. off versus collected=0 is preserved deliberately: the counting lives where the collector lives, not behind the seed gate, so a seeding-DISABLED app never looks like a seeding-BROKEN one.
Dev-only, because a production header would publish how many server calls a page made for no benefit.
3. Client counts, reported once per page view, gated by a SERVER-supplied marker
The sharpest constraint in this issue, and the answer is not the obvious one.
process.env.NODE_ENV cannot gate anything in the browser.scripts/build-framework-dist.js runs esbuild with platform: 'browser' and minify: true and no define (L83-86), and esbuild then substitutes process.env.NODE_ENV with the literal "production". Measured against the shipped bundle: packages/core/dist/webjs-core-browser.js contains the client router's fallback warning compiled to
The third conjunct of typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production' folded to a constant, so the guard now reads "does a process.env object exist". publicEnvShim (ssr.js L1296-1311) defines window.process.env on every page in both modes, so that guard is always true in a real app and the warning never fires in either mode. packages/core/src/component.js L136-143 already documents this trap for the SSR half; the browser half is worse, because the gate silently inverts instead of merely being dropped. Any client-side dev gate written as a NODE_ENV comparison in this codebase is dead code in every installed app.
So the dev signal comes from the server, on the page. buildSeedScript stamps data-webjs-dev="ok" (or "streamed") on the seed block when dev is true, and in dev the block is emitted even when empty, including on a streamed page where it carries only the marker. scanSeeds reads the marker and schedules one report. Prod output stays byte-identical (an empty collector still yields '', a non-empty one still yields an unmarked block).
Prior art for the shape: TanStack Router's hydrate() throws a loud dev-only invariant when the SSR bootstrap payload is missing and degrades to a silent invariant() in production (~/Documents/Projects/frameworks/tanstack-router/packages/router-core/src/ssr/ssr-client.ts L39-49), on exactly the "the hydration payload did not arrive" condition. It can afford process.env.NODE_ENV because the consuming app has a bundler. WebJs does not, which is the whole reason the marker exists. Remix takes the opposite position and needs no counter at all: its loader data is load-bearing rather than an optimization (~/Documents/Projects/frameworks/remix-v2/packages/remix-react/components.tsx L825-900 serializes __remixContext and the client hydrates from it unconditionally), so a missing payload is a hard failure, not a silent extra fetch. WebJs's seed is an optimization, which is precisely why it needs a channel Remix does not. Its once-per-cause dedupe helper (packages/remix-react/warnings.ts, warnOnce) is the same shape as WebJs's own warnOnce in router-client.js, and the determinism warning reuses that idea.
Where the count is aggregated, and when it reports. Consumption happens per RPC stub call (packages/server/src/actions.js L398 for the URL-arg verbs, L417 for the body verbs, both __seedTake(__HASH, fn, key)), spread across a page's components over time, so no single call site can report. The counters therefore live in action-seed-client.js beside the store, and reporting is one line at the first idle after the seeds were ingested (requestIdleCallback(fn, { timeout: 1000 }), falling back to setTimeout(fn, 250)).
That window IS the metric, which is why a timer and an on-demand API are both wrong. Rejected: report on an interval, which keeps firing for a page nobody is loading. Rejected: report only on demand through seedStats(), which fails the acceptance criterion, since the developer has to already suspect the problem. The window matters because a miss after hydration is correct behaviour: the seed is consume-once, so a refetch or an argument change is SUPPOSED to miss. Only calls inside the hydration window are wasted round-trips. A slow async render() whose call lands after idle is undercounted, which is the safe direction (a false negative, never a false alarm).
The report fires on the initial load (the lazy scan runs on the first takeSeed) and again per soft navigation (applySwap scans eagerly), so the soft-nav carrier, which can break independently of the first-load one, is covered.
The client logs only on a defect, meaning at least one miss inside the window. A healthy line every page view trains developers to filter the channel out, and the healthy number is already on the server line for every request. The message names the cause it can prove: streamed page, no seeds on the page, or seeds present with keys unmatched.
4. The determinism assertion lives in recordSeed and compares on the FULL key
Compared on hash/fn/argsKey, never on hash/fn. A legitimate second call with different args produces a different key and cannot false-fire. That is why the check goes in recordSeed, the one place the full key exists.
Comparison: Object.is first, then serialized equality.Object.is is free and settles a memoized or cached return. When it fails, the two values are compared through stringify, the same serializer the seed itself uses, so "different" means different on the wire, which is the only difference that can reach a client. Hand-rolled deep comparison is rejected (it would need its own cycle handling and could disagree with the wire); reference equality alone is rejected (an action returning a fresh object per call, the common case, would false-fire on every duplicate key).
Cost. Dev only, and only on a duplicate key, which requires the same action with the same args twice in one render. That value is going to be serialized anyway at buildSeedScript, so the marginal cost is one extra stringify per duplicated key, in dev, on a path that already awaits hashFile and stringify(args) per call. Production pays one boolean test.
Deduped on hash/fn while comparing on the full key. One warning per action function is what the developer needs, and it bounds the dedupe Set by the number of action functions rather than by the number of distinct argument lists.
5. Nothing is compiled out; the counters are always on
WebJs is no-build on the server (source is the runtime, so there is no dead-code elimination at all), and the one build that does exist, the core browser bundle, cannot express a dev gate (see 3). So the plan states the cost rather than pretending otherwise:
Client: four integer increments on a path that already awaits stringify(args) and usually a fetch. Unmeasurable.
Server: collector.size reads that already existed, plus one boolean test per recorded seed in prod.
Payload: prod HTML is byte-identical. The only dev-only page-weight change is an empty seed block on a page that previously emitted none.
6. No webjs doctor check
Decided: nothing is added to packages/cli/lib/doctor.js and no DOCTOR_CODES entry (L95-108) is created. doctor is a static, request-free project-health checklist. Every failure mode this issue addresses (a facade miss, a key mismatch, a hook that did not install, a page that streams) is only observable while rendering a real page, and the one thing a static check could report, that the seed switch is on, is already visible in package.json. A code that can only ever pass would also let #1257's webjs.doctor.gate gate a check that proves nothing. The observable surface is the dev header, the access-log field, and the dev console lines.
The correctness boundary, final wording (this text goes into the docs)
A seed hit returns the value the SSR render that produced this page computed for exactly this action, function, and argument list, so a hit cannot show the user something different from the HTML they are already looking at. On an HTML-cached page (export const revalidate) the seed rides inside the cached bytes, so it is exactly as fresh as the HTML it came with. A miss simply re-fetches. There is one shape where a hit can differ from the paint, and WebJs warns about it in dev: an action that returns a DIFFERENT result for the SAME arguments twice in one render, where the seed carries the last result while the first component painted the first one.
One residual caveat goes in the skill reference but not the marketing docs, because reaching it takes a mutation. The collector stores the live result object and serializes it after the render completes (buildSeedScript, L657), so a render that MUTATES an action's returned object after painting seeds the mutated value. Fixing that means serializing at record time, paying a full serialization for every action call including on pages that end up streamed and emit nothing, which is not worth it for an app that mutates a server result in place mid-render.
Implementation plan
Ordered. Every step names the file, the function, and the anchor at HEAD ddfc5547. Work in a worktree (git worktree add -b feat/seed-observability ../webjs-seed-observability origin/main, then npm run worktree:link inside it).
Step 1. packages/core/src/action-seed-client.js: last-write-wins, counters, the dev marker, the reporter
1a. Flip ingest to last-write-wins and count. Today (L55-76):
/** * Parse one serialized seed payload and merge it (first write wins, so an * already-consumed or earlier seed is never clobbered by a later duplicate), * then run `cleanup` (or remove the element). */functioningest(raw,el,cleanup){if(raw){try{constobj=parse(raw);if(obj&&typeofobj==='object'){for(constkinobj)if(!seeds.has(k))seeds.set(k,obj[k]);}}catch{// Malformed payload: ignore, the stub re-fetches.}}if(cleanup)cleanup();elseel.remove?.();}
After. The doc comment must be rewritten too, since its stated rationale describes a case that cannot occur:
/** * Parse one serialized seed payload and merge it, then run `cleanup` (or remove * the element). * * LAST write wins. A hit DELETES its key (`takeSeed`), so a duplicate can only * mean "a later render emitted a seed for a key an earlier render left * unconsumed", and the later render is the one whose paint is on screen. The * previous first-write-wins rule handed that component the OLDER value, the one * path where a hit could disagree with the visible HTML. It is reachable on a * soft navigation, on the background revalidation after one, and on a * back/forward snapshot restore, all of which route through `applySwap`'s * `scanSeeds(doc)`. */functioningest(raw,el,cleanup){if(raw){try{constobj=parse(raw);if(obj&&typeofobj==='object'){for(constkinobj){if(seeds.has(k))stats.replaced++;elsestats.ingested++;seeds.set(k,obj[k]);}}}catch{// Malformed payload: ignore, the stub re-fetches.}}if(cleanup)cleanup();elseel.remove?.();}
1b. Add the counters and the dev-marker state after SEED_MISS (L29):
/** * Cumulative counters. ALWAYS on: WebJs is no-build on the server, and the one * build that exists (the core browser bundle) cannot express a dev gate, since * esbuild substitutes `process.env.NODE_ENV` with "production" and * `publicEnvShim` always defines `window.process.env`, so a NODE_ENV guard in * this bundle is a constant. Four integer increments on a path that already * awaits `stringify(args)` and usually a `fetch`, so nothing is gated here; only * the REPORTING is, by a server-emitted marker (see `noteDevMarker`). */conststats={ingested: 0,replaced: 0,hits: 0,misses: 0};/** `null` in prod. In dev, the `data-webjs-dev` value the server stamped. */letdevMarker=null;/** One scheduled report at a time; each report covers the delta since the last. */letreportScheduled=false;letlastReport={hits: 0,misses: 0,ingested: 0};
1c. Read the marker in scanSeeds (L42-53). The marker rides only the page-level block, so read it in that loop, and schedule after both loops:
for(constelofscope.querySelectorAll('script[type="application/json"]#__webjs-seeds, script[type="application/json"][data-webjs-seeds]')){noteDevMarker(el.getAttribute?.('data-webjs-dev'));ingest(el.textContent,el);}// ... the existing [data-webjs-seed] loop, unchanged ...scheduleSeedReport();
1d. Add noteDevMarker, scheduleSeedReport, reportSeeds. All three are best-effort and must never throw into a render or a navigation.
/** @param {string | null | undefined} v the server's `data-webjs-dev` value */functionnoteDevMarker(v){if(typeofv==='string')devMarker=v||'ok';}/** * Schedule the one dev report for this page view. The window it measures runs * from the scan to the idle callback, which is the hydration window: a miss * inside it is a wasted round-trip, while a miss AFTER it is correct (the seed * is consume-once, so a refetch or an argument change is supposed to miss). A * slow `async render()` whose call lands after idle is undercounted, a false * negative rather than a false alarm. */functionscheduleSeedReport(){if(!devMarker||reportScheduled)return;reportScheduled=true;construn=()=>{reportScheduled=false;try{reportSeeds();}catch{/* diagnostics never break a page */}};try{if(typeofrequestIdleCallback==='function')requestIdleCallback(run,{timeout: 1000});elsesetTimeout(run,250);}catch{reportScheduled=false;}}/** * One dev console line per page view, and ONLY on a defect. A healthy line every * navigation trains a developer to filter the channel out, and the healthy * number is already on the server's access-log line for every request. */functionreportSeeds(){consthits=stats.hits-lastReport.hits;constmisses=stats.misses-lastReport.misses;constingested=stats.ingested-lastReport.ingested;lastReport={hits: stats.hits,misses: stats.misses,ingested: stats.ingested};if(misses===0)return;constcause=devMarker==='streamed'
? 'This page streams (a Suspense or <webjs-suspense> boundary), and a streamed render emits no seeds, so every action call on it goes to the network.'
: ingested===0
? 'The page carried no seeds at all. Check that the action lives in a *.server.{js,ts} file whose head declares \'use server\', and that a component actually awaited it during the SSR render.'
: 'The page carried seeds, but not for these calls. The key is the action file hash plus the function name plus the serialized arguments, so a different argument misses (a deliberate refetch after hydration misses too, and is expected).';console.warn(`[webjs] SSR action seeding: ${misses} of ${hits+misses} hydration action call(s) missed the seed and cost a network round-trip `+`(${ingested} seed(s) on this page, ${seeds.size} still unconsumed). ${cause} `+'See https://webjs.dev/docs/data-fetching for the seeding reference.');}
1e. Count in takeSeed (L89-101): stats.hits++ beside the seeds.delete(key), and stats.misses++ before return SEED_MISS.
1f. Export seedStats, extend __resetSeeds:
/** * The cumulative seed counters for this page session. `ingested` and `replaced` * are what `scanSeeds` merged; `hits` and `misses` are what the generated RPC * stubs asked for; `pending` is what is still in the store unconsumed (a * non-zero `pending` at rest usually means the seeding component elided, so * nothing on the client was ever going to call it). * @returns {{ ingested: number, replaced: number, hits: number, misses: number, pending: number }} */exportfunctionseedStats(){return{ ...stats,pending: seeds.size};}
__resetSeeds (L104-107) also resets stats, devMarker, reportScheduled, and lastReport.
Step 2. Declare and re-export seedStats
packages/core/src/action-seed-client.d.ts: add the declaration beside takeSeed.
packages/core/index.js L43 and packages/core/index-browser.js L67: add seedStats to the existing export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js';.
packages/core/index.d.ts L111: the same addition.
Enforced by test/types/dts-export-coverage.test.mjs, which fails on a runtime export with no declaration. seedStats is public on purpose: it is what makes the counting assertable from a browser test, and from an app's own.
Step 3. packages/server/src/action-seed.js: the dev flag, the determinism assertion, the dev marker
3a. Thread dev.registerActionHooks(opts) (L605-630) is the one boot call. Add the module flag beside _seedEnabled (L76-79):
/** Dev mode, threaded from `dev.js` at boot. Gates the determinism assertion. */let_devMode=false;
and set it beside _seedEnabled = opts.seed !== false; (L606), which sits BEFORE the _registered idempotency guard, so a second call still updates the flags:
Update the JSDoc @param from {{ seed?: boolean }} to {{ seed?: boolean, dev?: boolean }}.
3b. The determinism assertion in recordSeed (L189-201). Today:
asyncfunctionrecordSeed(collector,file,fnName,args,value){if(isStreamable(value))return;try{consthash=awaitactionFileHash(file);constargsKey=awaitstringify(args);collector.set(`${hash}/${fnName}/${argsKey}`,value);}catch{// Drop the seed; the client stub falls back to a normal RPC.}}
After. Note the placement: the assertion sits in its OWN try/catch INSIDE the existing one, so a failure in the diagnostic can never skip collector.set and turn an observability feature into a dropped seed. That is the fail-open contract (L52-57) applied to the new code, and it is exactly what the fault-injection test pins.
asyncfunctionrecordSeed(collector,file,fnName,args,value){if(isStreamable(value))return;try{consthash=awaitactionFileHash(file);constargsKey=awaitstringify(args);constkey=`${hash}/${fnName}/${argsKey}`;// Dev-only determinism assertion. A duplicate key means the SAME action ran// twice with the SAME arguments in ONE render; the collector keeps the LAST// result, so a component that painted the first one hydrates with the// second. Compared on the FULL key, never on `hash/fn`: a legitimate second// call with different arguments has a different key and cannot false-fire.// In its own try/catch so a diagnostic failure never drops the seed.if(_devMode&&collector.has(key)){try{awaitassertDeterministic(collector.get(key),value,hash,fnName);}catch{/* never affect the seed */}}collector.set(key,value);}catch{// Drop the seed; the client stub falls back to a normal RPC.}}/** Warned-once ids, keyed `hash/fn` so the Set is bounded by the action count. */const_nonDeterministic=newSet();/** * Warn (once per action function) when one render recorded two DIFFERENT results * for the same key. `Object.is` settles a memoized return for free; the fallback * compares through the SAME serializer the seed uses, so "different" means * different on the wire, which is the only difference that can reach a client. * Dev only, and only on a duplicate key. */asyncfunctionassertDeterministic(prev,next,hash,fnName){if(Object.is(prev,next))return;constid=`${hash}/${fnName}`;if(_nonDeterministic.has(id))return;if((awaitstringify(prev))===(awaitstringify(next)))return;_nonDeterministic.add(id);console.warn(`[webjs] SSR action seeding: "${fnName}" returned two DIFFERENT results for the SAME arguments during one render. `+'The seed carries the LAST result, so a component that painted the first one hydrates with the second. '+'Make the action deterministic for a given argument list, or turn seeding off with "webjs": { "seed": false }.');}
3c. The dev marker in buildSeedScript (L657-673). Today it returns '' for an empty collector. After, it takes an options bag and, in dev only, emits the block even when empty, so the client always has a marker to report against:
/** * @param {Map<string, unknown>} collector * @param {{ dev?: boolean, reason?: string }} [opts] dev stamps a * `data-webjs-dev` marker (the ONLY dev signal the browser gets, since a * NODE_ENV gate is a compile-time constant in the built core bundle) and emits * the block even when EMPTY, so a page that seeded nothing is still reportable. * @returns {Promise<string>} */exportasyncfunctionbuildSeedScript(collector,opts={}){constdev=opts.dev===true;if((!collector||collector.size===0)&&!dev)return'';try{constobj={};if(collector)for(const[k,v]ofcollector)obj[k]=v;constpayload=awaitstringify(obj);constsafe=payload.replace(/</g,'\\u003c')// ... the remaining escapes, unchanged ...constmarker=dev ? ` data-webjs-dev="${opts.reason||'ok'}"` : '';return`<script type="application/json" id="__webjs-seeds"${marker}>${safe}</script>`;}catch{return'';}}
Prod output is unchanged byte for byte (empty collector still '', non-empty still an unmarked block), which a test asserts explicitly.
Step 4. packages/server/src/ssr.js: emit in the streamed case too, and stamp the header
After. Prod behaviour is identical: a streamed page still emits nothing, a buffered page emits exactly what it emitted before. In dev a streamed page emits the marker-only block, which is what lets the client say WHY every call missed.
letoutBody=streamBody;letseedHeader='off';conststreamed=suspenseCtx.pending.length>0;if(seedCollector&&streamed){// A streamed render's deferred boundaries resolve AFTER the first flush,// so their results cannot ride this block and none is emitted in prod. In// DEV emit the marker alone, so the client reports the cause instead of// leaving the developer to guess why every call went to the network.seedHeader=`collected=${seedCollector.size}, emitted=0, streamed`;if(opts.dev){constmarker=awaitbuildSeedScript(newMap(),{dev: true,reason: 'streamed'});if(marker)outBody=streamBody+marker;}}elseif(seedCollector){constseedScript=awaitbuildSeedScript(seedCollector,{dev: opts.dev});if(seedScript)outBody=streamBody+seedScript;// `emitted` differs from `collected` exactly when the serializer threw and// dropped the whole block, which is otherwise invisible.seedHeader=`collected=${seedCollector.size}, emitted=${seedScript ? seedCollector.size : 0}`;}
4b. Stamp the header on the response, immediately after const res = streamingHtmlResponse(...) (L295-309) and before the reduced block (L327):
// Dev-only seeding diagnostics (#1309). A miss is indistinguishable from a// hit from the outside, so an app whose seeding silently broke looks exactly// like one where it works. Dev only: a production header would publish how// many server calls a page made, for no benefit.if(opts.dev)res.headers.set('X-Webjs-Seed',seedHeader);
4c. The HTML-cache hit path (L58-60). A cache hit returns before any seed work, so it would report nothing at all, which reads as "seeding is broken" when it is the cache answering. Today:
consthit=awaitreadHtmlCache(url);if(hit){constcached=cachedHtmlResponse(hit,opts.req,url);// The seed block rides INSIDE the cached bytes, so the seeds are// exactly as fresh as the HTML. Say so rather than reporting zero.if(opts.dev)cached.headers.set('X-Webjs-Seed','html-cache');returncached;}
Step 5. packages/server/src/dev.js: pass dev, fold the header into the access log
5a. L653, inside createRequestHandler (L507-1604), where const dev = !!opts.dev is already in scope (L533):
-awaitregisterActionHooks({seed: awaitreadSeedEnabled(appDir)});+awaitregisterActionHooks({seed: awaitreadSeedEnabled(appDir), dev });
5b. The access log (L1319-1325), also inside createRequestHandler, so the same dev binding applies. One extra field, dev only, present only on a response that carried the header (so only page renders). No new line, no prod change:
if(shouldAccessLog(headerPathname)){try{constseed=dev ? conditioned.headers.get('x-webjs-seed') : null;logger.info?.('request',{requestId: reqId,method: req.method,path: pathname,status: conditioned.status,durationMs: Math.round((performance.now()-startedAt)*100)/100,
...(seed ? { seed } : {}),});}catch{/* never let logging crash the response */}}
Tests
Server unit, packages/server/test/seed/
action-seed-unit.test.js (extend).registerActionHooks sets _seedEnabled / _devMode BEFORE its _registered idempotency guard (L606-608), so a second call in a test still flips the flags.
Two recordSeed calls with the same key and DIFFERENT values warn exactly once (capture console.warn).
Two calls with the same key and structurally EQUAL but distinct objects do NOT warn (Object.is fails, serialized equality settles it).
Two calls with the same hash/fn and DIFFERENT args do NOT warn. This is the counterfactual for comparing on the full key: it fails if the implementer keys the check on hash/fn.
With dev: false, a differing duplicate does NOT warn.
Fault injection, the fail-open contract. Stub console.warn to throw, record two differing values for one key, and assert collectSeeds still returns the collector with the entry and buildSeedScript still serializes it. Fails if the assertion is not in its own try/catch.
buildSeedScript(new Map(), { dev: true }) emits a data-webjs-dev="ok" marker block, buildSeedScript(new Map()) returns '', and a non-empty collector with no opts is byte-identical to today (no marker attribute). Prod byte-identity counterfactual.
seed-ssr.test.js (extend). A dev: true handler's GET / carries X-Webjs-Seed: collected=1, emitted=1 and the body's seed block carries data-webjs-dev="ok". Assert the seed key and value assertions already in the file still pass unchanged.
seed-ssr-off.test.js (extend). With seeding off, a dev render's header is exactly off. This is the "a seeding-disabled app must not look like a seeding-broken app" requirement, and it is why the counting lives with the collector rather than behind seedingEnabled().
packages/server/test/seed/seed-observability.test.js (NEW). Its own file because module.registerHooks is process-global, the same reason seed-ssr-off.test.js is separate.
A prod (dev: false) render carries NO X-Webjs-Seed header and NO data-webjs-dev marker. The prod-leak counterfactual.
A page with a Suspense / <webjs-suspense> boundary carries the header collected=<n>, emitted=0, streamed. In dev the body carries a marker-only block with data-webjs-dev="streamed", and in prod it carries no block at all (today's behaviour, unchanged).
seed-hook.test.js, seed-switch.test.js: unchanged. No new config key is added, so the webjs.* three-way lockstep (schema + WebjsConfig + reader + KNOWN_KEYS) does not apply.
Client unit, packages/core/test/seed/action-seed-client.test.js
Invert the existing test at L74-79 (first-write-wins: an earlier seed is not clobbered by a duplicate key) into last-write-wins: a later render's seed replaces an unconsumed earlier one, with a comment naming why the old rule protected an unreachable case.
The staleness counterfactual: scan render 1's payload, consume nothing, scan render 2's payload for the same key, and assert takeSeed returns render 2's value. Fails on revert of step 1a.
seedStats() reports ingested, replaced, hits, misses, pending across a scan plus a hit plus a miss.
A carrier stamped data-webjs-dev="ok" schedules exactly one report (stub globalThis.requestIdleCallback and console.warn); an unmarked carrier schedules none. The prod-silence counterfactual.
The report fires only on a defect: all hits, no line; one miss inside the window, one line naming the cause.
Fault injection: console.warn throws inside the report and takeSeed still returns its value with nothing propagating.
Browser, packages/core/test/seed/browser/action-seed-client.test.js (NEW folder + file)
A unit test is necessary but not sufficient for browser-facing behaviour, and the existing fake DOM in the unit file cannot prove the real one. Against a real document (Chromium / Firefox / WebKit via web-test-runner):
A real <script type="application/json" id="__webjs-seeds" data-webjs-dev="ok"> is ingested and REMOVED from the DOM.
A real [data-webjs-seed] element carrier is ingested and its attribute stripped (the second carrier, which the server does not yet emit but the client reads).
The real requestIdleCallback path fires exactly one report per scan batch.
Modelled on test/e2e/dev-overlay-nav.test.mjs, which stages its fixture to a temp dir with symlinked @webjsdev/* (a worktree has no node_modules) and runs a real webjs dev. The existing prod-mode seeding e2e at test/e2e/e2e.test.mjs L489 stays unchanged. Gated behind WEBJS_E2E=1 like its neighbours. The fixture carries a root layout, a page rendering a SHIPPING async component (a signal plus an @click, so it is not elided) that awaits a 'use server' action, and a second page whose component calls that action with an argument the SSR render never used.
GET / in dev responds with X-Webjs-Seed: collected=1, emitted=1.
Loading / in a real browser fires NO /__webjs/action/ request on hydration and logs NO [webjs] SSR action seeding warning.
Loading the second page logs exactly one [webjs] SSR action seeding warning naming the unmatched-keys cause. This is the headline acceptance criterion driven end to end: a broken seeding path is visible without opening the network tab.
Bun parity, test/bun/ (mandatory)
The install mechanism is the one genuinely runtime-specific half (#529): Node's module.registerHooks versus a Bun.pluginonLoad, chosen by serverRuntime() in registerActionHooks (L610-616). A facade regression on one runtime leaves the other green, and the COUNT is a direct function of whether the facade ran, so this is exactly the assertion that has to run on both.
test/bun/seed.mjs (extend). Keep every existing assertion. Add a createRequestHandler({ appDir, dev: true }) pass asserting X-Webjs-Seed: collected=1, emitted=1 and data-webjs-dev="ok" in the body. A Bun-side facade regression then reads collected=0 on Bun and collected=1 on Node.
test/bun/seed.test.mjs: unchanged (it only imports the script under whichever runtime runs the suite).
The determinism assertion is runtime-neutral (both installs feed the same recordSeed), so it is proven once in the Node unit test rather than duplicated.
Run and report: node scripts/run-bun-tests.js, plus node test/bun/seed.mjs and bun test/bun/seed.mjs.
Layers that do NOT apply
Smoke (test/examples/*/smoke/*): the scaffold gallery demos no seeding surface and this adds no API an app writes.
webjs check rules: nothing here is a correctness rule an app could violate in source.
webjs doctor: see the Design section. No check, no DOCTOR_CODES entry.
Config schema / type drift tests: no webjs.* key is added.
Docs
Surface
Change
AGENTS.md L271 (the seeding sentences closing the async-render paragraph)
Replace the "fail-open (a miss degrades to a normal RPC, never wrong data)" clause with the corrected correctness boundary, and add one clause naming the dev observability (X-Webjs-Seed, the access-log seed field, the browser warning). Keep the edit inside that sentence run: #1307 and #1308 edit other sections of this file.
The skill documents seeding nowhere today (grep for "seed" finds only the passing mention at L146). Add a new ### SSR action seeding section as a pure APPEND-shaped insertion (#1307 also edits this file): what it is, the correctness boundary verbatim from the Design section, the dev observability, the streamed-page exception, the note that a seed emitted for a component that then ELIDES is never consumed (visible as a non-zero pending in seedStats()), and the seed kill switch.
Add registerActionHooks({ seed, dev }), the dev determinism assertion, the buildSeedScript(collector, { dev, reason }) marker, and the X-Webjs-Seed header.
Add seedStats(), the last-write-wins ingest rule, and the marker-driven dev report.
website/app/docs/configuration/page.ts
NEW <h2>SSR action seeding</h2> inserted immediately BEFORE <h2>Request limits & server timeouts</h2> (L132), documenting "webjs": { "seed": false } and WEBJS_SEED=0 plus the dev header. WEBJS_SEED is absent from this page today (verified). Landmine: #1308 adds the elide opt-out to this same page. Keep this a pure insertion at that one anchor; if #1308 lands first, insert after its section.
website/app/docs/data-fetching/page.ts L68-70
Correct the closing claim of the seeding paragraph to the boundary wording, and add the dev observability sentence.
website/app/docs/server-actions/page.ts L205-206
Same correction to the "No re-fetch on hydration (SSR seeding)" paragraph.
blog/ssr-action-seeding-no-refetch.md
Checked, no edit. L61 ("There is no path where a stale or mismatched seed gets served") was already inaccurate at HEAD for the cross-render carry-over case, and step 1 makes it true, so the post stops being stale rather than becoming stale. It is a dated artifact governed by the webjs-blog-write skill; leave it alone.
No change. Seeding is automatic and the new surface is a dev diagnostic. The scaffold picks the skill edit up for free: create.js L664-674 copies the repo-root .agents/skills/webjs/.
Run webjs check and webjs doctor over website and examples/blog after the docs edits (the required conventions CI job runs doctor over both, #1257).
Acceptance criteria
X-Webjs-Seed is set on every dev SSR page response, with off when seeding is disabled, html-cache on a Add a server HTML response cache with TTL and on-demand revalidation #241 cache hit, collected=<m>, emitted=<n> on a buffered render, and collected=<m>, emitted=0, streamed on a streamed one
The value is folded into the existing dev access-log line as a seed field, adding no new log line
No X-Webjs-Seed header, no data-webjs-dev marker, and no console output reaches production, and prod HTML is byte-identical to HEAD (asserted, not assumed)
In dev the browser logs one warning per page view when a hydration action call missed its seed, naming which of the three causes applies (streamed page, no seeds on the page, keys unmatched), and stays SILENT when every call hit
The report covers both carriers the client reads (the page-level #__webjs-seeds block and per-element [data-webjs-seed]) and fires on a soft navigation as well as the initial load
The client dev gate comes from the server-emitted marker, never from process.env.NODE_ENV, which is a compile-time constant in the built core bundle
seedStats() is exported from @webjsdev/core, declared in the .d.ts overlay, and reports ingested / replaced / hits / misses / pending
Dev warns once per action function when one render records two different results for the same hash/fn/argsKey, and never warns for the same function called with different args
A later render's seed replaces an unconsumed earlier seed for the same key, so a hit is always the value the visible paint used
The fail-open contract holds: a throwing console.warn in the determinism path still records the seed and still emits the block, proven by a fault-injection test
Bun parity asserted for the count on both install mechanisms (node test/bun/seed.mjs and bun test/bun/seed.mjs both green)
Tests at unit, browser, e2e, and Bun layers, each with the counterfactual named above
Docs state the correctness boundary and document the seed kill switch on the configuration page
Out of scope
Emitting per-element data-webjs-seed carriers, or seeding streamed regions at all. The client already reads that carrier and the server has never written one. Making a streamed boundary carry its own seed is a real feature and a separate task; this issue only makes the current gap visible and names it in the report.
Skipping seeds for components that elide. A seed recorded for a component that the framework then elides is dead page weight that nothing will ever consume. The ALS collector has no component attribution, so suppressing it needs a design. Surfacing it as a non-zero pending in seedStats() is this issue's whole contribution there.
Any expiry, cap, or eviction policy on the client seed store. The only lifetime change is the last-write-wins flip.
Serializing the result at record time to close the post-return-mutation caveat. It would cost a full serialization per action call on every page including streamed ones that emit nothing.
A webjs doctor check or a new DOCTOR_CODES entry. Decided against with reasons in the Design section.
A new webjs.* config key. The reporting is dev-only and needs no switch, so nothing touches the schema / WebjsConfig / reader lockstep.
Problem
SSR action seeding (#472) is the safest of the three inference-based subsystems, and its real risk is misread. A miss degrades to a normal RPC. The whole feature is fail-open by construction (
packages/server/src/action-seed.jsL52-57, restated at L468).The actual production risk is a silent performance regression. Because a miss is indistinguishable from a hit from the outside, a refactor that breaks seeding for an entire app produces no error, no warning, and no log line. The app just quietly re-issues one RPC per async component on every first load, which is exactly what the feature exists to remove. Three real ways it breaks silently, all fail-open by design:
extractExportNames(L365) andbuildSeedFacade(L551) are deliberately conservative. A name the extractor misses stays a plain value binding, so the export is never wrapped and never seeded. That is a MISS, not a crash.module.registerHooks, Bun uses aBun.pluginonLoad, selected byserverRuntime()inregisterActionHooks(L605-630). A regression on one runtime leaves the other green.hashFile(file)/fn/stringify(args)(L189-197), computed independently on both sides. Any drift in either half makes every lookup miss.There is a second, narrower gap. An action that is not deterministic for a given argument list is the one shape where a hit can surprise the author, and nothing detects it today.
recordSeeddoescollector.set(key, value)(L197) with no check, so the LAST result for a key wins while the FIRST component already painted the first result.What was verified, and what was wrong in the previous statement of this issue
Every anchor the previous body cited is correct at
ddfc5547:seedingEnabled()L118,identityHookInstalled()L128,actionIdentityOf()L139,actionFileHash()L168,recordSeedL189 with the keying at L196-197,__actionWrapL225, the Proxyapplytrap L257-273 (async record at L265, sync at L273),extractExportNamesL365,buildSeedFacadeL551,registerActionHooksL605,collectSeedsL641,buildSeedScriptL657, and on the client theseedsMap L26,SEED_MISSL29,scanSeedsL42. Two CLAIMS in that body do not survive contact with the code.(corrected) Per-element
data-webjs-seedcarriers have no producer.scanSeeds(L42-53) reads two carriers, the page-level#__webjs-seedsblock and per-element[data-webjs-seed]attributes. A repo-wide grep findsdata-webjs-seedonly in core, its.d.ts, its unit test, and two doc lines. The server never emits one. The module header calling it "for streamed regions" is aspirational:ssr.jsL291 emits the seed block only whensuspenseCtx.pending.length === 0, so a streamed page emits no seeds at all, not per-element ones. Counting must still cover both carriers, because the client reads both, but nothing here may claim streamed regions are seeded today.(corrected) The correctness boundary is not unconditionally true. Measured, not reasoned. The client seed store is module-global and
ingest(L63-76) is FIRST-WRITE-WINS (if (!seeds.has(k)) seeds.set(k, obj[k])). AtakeSeedhit DELETES the key (L97), so the stated rationale ("an already-consumed seed is never clobbered") describes a case that cannot occur. What the rule actually does is keep an unconsumed seed from an earlier render in preference to a fresher one for the same key. Reproduced against the real module:So today a hit can hand a component a value from a render that is no longer on screen. It is reachable through three paths that all funnel into
applySwap(packages/core/src/router-client.jsL2966scanSeeds(doc)): a soft navigation, the background revalidation after one, and a back/forward snapshot restore (L1368, which parses the cached HTML and applies it through the same path). The snapshot can still carry a seed block because the initial scan is LAZY (firsttakeSeed, L90-93), so a page whose seeding components all elided never scans, never removes the block, and snapshots it intact.The rest of the boundary claim holds and was checked: a same-render hit returns the exact object the awaiting
async render()received (seedProxyL264-267 records the resolved value and returns it unchanged); a second consumer of the same key simply misses and re-fetches; a streamed result is skipped outright (L193); and an HTML-cached page (#241) carries its seed block inside the cached bytes, so the seed is exactly as fresh as the HTML it rode in on.Design / approach
Six decisions. None changes the wire format, the default, or the fail-open contract.
1. Make the boundary claim true before documenting it:
ingestbecomes last-write-winsFlip
ingestto overwrite. The newest render is always the one whose paint is on screen, on every path that reachesscanSeeds(initial load, soft nav, revalidation, snapshot restore), so last-write-wins is the rule that makes "a hit is the value the paint you are looking at used" true rather than nearly true. First-write-wins protects nothing (its stated case is unreachable) and is the only reason the sentence needed a caveat.One line, plus an inverted existing test. WebJs has no users, so a clean change beats documenting a stale-data path in a feature whose selling point is "never wrong data" (the repo rule is to prefer a clean break over a back-compat shim).
Rejected: keep first-write-wins and document the caveat. The docs deliverable of this issue is the correctness sentence, and shipping that sentence with a stale-data asterisk is worse than deleting the asterisk. Rejected: clear the whole store on every navigation. An in-flight
async render()from the outgoing page can still be about to consume a legitimate seed, and dropping it costs a round-trip for no correctness gain.2. Server counts ride a dev-only response header, folded into the existing access log
Emit
X-Webjs-Seedfromssr.js, and fold its value into the ONE structured access-log linedev.jsalready writes per request (L1319-1325). No new console line, no new log volume.Prior art: Next.js reports cache state exactly this way, an
x-nextjs-cacheheader carryingHIT/MISS/STALE/REVALIDATED(~/Documents/Projects/frameworks/next.js/packages/next/src/build/templates/app-page-runtime.tsL1670-1691, and again inpackages/next/src/server/route-modules/pages/pages-handler.tsL487). A header is the right carrier because it is per response, needs no client, survivescurl, and is trivial to assert in a test. WebJs already ships sixX-Webjs-*headers, so neither the name nor the shape needs inventing.Header values, decided:
seedingEnabled()false)offhtml-cachecollected=<m>, emitted=<n>Suspense/<webjs-suspense>boundary)collected=<m>, emitted=0, streamedcollectediscollector.sizeandemittedis the number of keys that actually reached the page. They differ exactly whenbuildSeedScript'sstringifythrew and dropped the whole block (L670-672), which is today a completely invisible failure.offversuscollected=0is preserved deliberately: the counting lives where the collector lives, not behind the seed gate, so a seeding-DISABLED app never looks like a seeding-BROKEN one.Dev-only, because a production header would publish how many server calls a page made for no benefit.
3. Client counts, reported once per page view, gated by a SERVER-supplied marker
The sharpest constraint in this issue, and the answer is not the obvious one.
process.env.NODE_ENVcannot gate anything in the browser.scripts/build-framework-dist.jsruns esbuild withplatform: 'browser'andminify: trueand nodefine(L83-86), and esbuild then substitutesprocess.env.NODE_ENVwith the literal"production". Measured against the shipped bundle:packages/core/dist/webjs-core-browser.jscontains the client router's fallback warning compiled toThe third conjunct of
typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production'folded to a constant, so the guard now reads "does aprocess.envobject exist".publicEnvShim(ssr.jsL1296-1311) defineswindow.process.envon every page in both modes, so that guard is always true in a real app and the warning never fires in either mode.packages/core/src/component.jsL136-143 already documents this trap for the SSR half; the browser half is worse, because the gate silently inverts instead of merely being dropped. Any client-side dev gate written as aNODE_ENVcomparison in this codebase is dead code in every installed app.So the dev signal comes from the server, on the page.
buildSeedScriptstampsdata-webjs-dev="ok"(or"streamed") on the seed block whendevis true, and in dev the block is emitted even when empty, including on a streamed page where it carries only the marker.scanSeedsreads the marker and schedules one report. Prod output stays byte-identical (an empty collector still yields'', a non-empty one still yields an unmarked block).Prior art for the shape: TanStack Router's
hydrate()throws a loud dev-only invariant when the SSR bootstrap payload is missing and degrades to a silentinvariant()in production (~/Documents/Projects/frameworks/tanstack-router/packages/router-core/src/ssr/ssr-client.tsL39-49), on exactly the "the hydration payload did not arrive" condition. It can affordprocess.env.NODE_ENVbecause the consuming app has a bundler. WebJs does not, which is the whole reason the marker exists. Remix takes the opposite position and needs no counter at all: its loader data is load-bearing rather than an optimization (~/Documents/Projects/frameworks/remix-v2/packages/remix-react/components.tsxL825-900 serializes__remixContextand the client hydrates from it unconditionally), so a missing payload is a hard failure, not a silent extra fetch. WebJs's seed is an optimization, which is precisely why it needs a channel Remix does not. Its once-per-cause dedupe helper (packages/remix-react/warnings.ts,warnOnce) is the same shape as WebJs's ownwarnOnceinrouter-client.js, and the determinism warning reuses that idea.Where the count is aggregated, and when it reports. Consumption happens per RPC stub call (
packages/server/src/actions.jsL398 for the URL-arg verbs, L417 for the body verbs, both__seedTake(__HASH, fn, key)), spread across a page's components over time, so no single call site can report. The counters therefore live inaction-seed-client.jsbeside the store, and reporting is one line at the first idle after the seeds were ingested (requestIdleCallback(fn, { timeout: 1000 }), falling back tosetTimeout(fn, 250)).That window IS the metric, which is why a timer and an on-demand API are both wrong. Rejected: report on an interval, which keeps firing for a page nobody is loading. Rejected: report only on demand through
seedStats(), which fails the acceptance criterion, since the developer has to already suspect the problem. The window matters because a miss after hydration is correct behaviour: the seed is consume-once, so a refetch or an argument change is SUPPOSED to miss. Only calls inside the hydration window are wasted round-trips. A slowasync render()whose call lands after idle is undercounted, which is the safe direction (a false negative, never a false alarm).The report fires on the initial load (the lazy scan runs on the first
takeSeed) and again per soft navigation (applySwapscans eagerly), so the soft-nav carrier, which can break independently of the first-load one, is covered.The client logs only on a defect, meaning at least one miss inside the window. A healthy line every page view trains developers to filter the channel out, and the healthy number is already on the server line for every request. The message names the cause it can prove: streamed page, no seeds on the page, or seeds present with keys unmatched.
4. The determinism assertion lives in
recordSeedand compares on the FULL keyhash/fn/argsKey, never onhash/fn. A legitimate second call with different args produces a different key and cannot false-fire. That is why the check goes inrecordSeed, the one place the full key exists.Object.isfirst, then serialized equality.Object.isis free and settles a memoized or cached return. When it fails, the two values are compared throughstringify, the same serializer the seed itself uses, so "different" means different on the wire, which is the only difference that can reach a client. Hand-rolled deep comparison is rejected (it would need its own cycle handling and could disagree with the wire); reference equality alone is rejected (an action returning a fresh object per call, the common case, would false-fire on every duplicate key).buildSeedScript, so the marginal cost is one extrastringifyper duplicated key, in dev, on a path that already awaitshashFileandstringify(args)per call. Production pays one boolean test.hash/fnwhile comparing on the full key. One warning per action function is what the developer needs, and it bounds the dedupe Set by the number of action functions rather than by the number of distinct argument lists.5. Nothing is compiled out; the counters are always on
WebJs is no-build on the server (source is the runtime, so there is no dead-code elimination at all), and the one build that does exist, the core browser bundle, cannot express a dev gate (see 3). So the plan states the cost rather than pretending otherwise:
stringify(args)and usually afetch. Unmeasurable.collector.sizereads that already existed, plus one boolean test per recorded seed in prod.6. No
webjs doctorcheckDecided: nothing is added to
packages/cli/lib/doctor.jsand noDOCTOR_CODESentry (L95-108) is created.doctoris a static, request-free project-health checklist. Every failure mode this issue addresses (a facade miss, a key mismatch, a hook that did not install, a page that streams) is only observable while rendering a real page, and the one thing a static check could report, that theseedswitch is on, is already visible inpackage.json. A code that can only ever pass would also let #1257'swebjs.doctor.gategate a check that proves nothing. The observable surface is the dev header, the access-log field, and the dev console lines.The correctness boundary, final wording (this text goes into the docs)
One residual caveat goes in the skill reference but not the marketing docs, because reaching it takes a mutation. The collector stores the live result object and serializes it after the render completes (
buildSeedScript, L657), so a render that MUTATES an action's returned object after painting seeds the mutated value. Fixing that means serializing at record time, paying a full serialization for every action call including on pages that end up streamed and emit nothing, which is not worth it for an app that mutates a server result in place mid-render.Implementation plan
Ordered. Every step names the file, the function, and the anchor at HEAD
ddfc5547. Work in a worktree (git worktree add -b feat/seed-observability ../webjs-seed-observability origin/main, thennpm run worktree:linkinside it).Step 1.
packages/core/src/action-seed-client.js: last-write-wins, counters, the dev marker, the reporter1a. Flip
ingestto last-write-wins and count. Today (L55-76):After. The doc comment must be rewritten too, since its stated rationale describes a case that cannot occur:
1b. Add the counters and the dev-marker state after
SEED_MISS(L29):1c. Read the marker in
scanSeeds(L42-53). The marker rides only the page-level block, so read it in that loop, and schedule after both loops:1d. Add
noteDevMarker,scheduleSeedReport,reportSeeds. All three are best-effort and must never throw into a render or a navigation.1e. Count in
takeSeed(L89-101):stats.hits++beside theseeds.delete(key), andstats.misses++beforereturn SEED_MISS.1f. Export
seedStats, extend__resetSeeds:__resetSeeds(L104-107) also resetsstats,devMarker,reportScheduled, andlastReport.Step 2. Declare and re-export
seedStatspackages/core/src/action-seed-client.d.ts: add the declaration besidetakeSeed.packages/core/index.jsL43 andpackages/core/index-browser.jsL67: addseedStatsto the existingexport { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js';.packages/core/index.d.tsL111: the same addition.Enforced by
test/types/dts-export-coverage.test.mjs, which fails on a runtime export with no declaration.seedStatsis public on purpose: it is what makes the counting assertable from a browser test, and from an app's own.Step 3.
packages/server/src/action-seed.js: the dev flag, the determinism assertion, the dev marker3a. Thread
dev.registerActionHooks(opts)(L605-630) is the one boot call. Add the module flag beside_seedEnabled(L76-79):and set it beside
_seedEnabled = opts.seed !== false;(L606), which sits BEFORE the_registeredidempotency guard, so a second call still updates the flags:Update the JSDoc
@paramfrom{{ seed?: boolean }}to{{ seed?: boolean, dev?: boolean }}.3b. The determinism assertion in
recordSeed(L189-201). Today:After. Note the placement: the assertion sits in its OWN try/catch INSIDE the existing one, so a failure in the diagnostic can never skip
collector.setand turn an observability feature into a dropped seed. That is the fail-open contract (L52-57) applied to the new code, and it is exactly what the fault-injection test pins.3c. The dev marker in
buildSeedScript(L657-673). Today it returns''for an empty collector. After, it takes an options bag and, in dev only, emits the block even when empty, so the client always has a marker to report against:Prod output is unchanged byte for byte (empty collector still
'', non-empty still an unmarked block), which a test asserts explicitly.Step 4.
packages/server/src/ssr.js: emit in the streamed case too, and stamp the header4a. The emit block (L284-294). Today:
After. Prod behaviour is identical: a streamed page still emits nothing, a buffered page emits exactly what it emitted before. In dev a streamed page emits the marker-only block, which is what lets the client say WHY every call missed.
4b. Stamp the header on the response, immediately after
const res = streamingHtmlResponse(...)(L295-309) and before thereducedblock (L327):4c. The HTML-cache hit path (L58-60). A cache hit returns before any seed work, so it would report nothing at all, which reads as "seeding is broken" when it is the cache answering. Today:
After:
Step 5.
packages/server/src/dev.js: passdev, fold the header into the access log5a. L653, inside
createRequestHandler(L507-1604), whereconst dev = !!opts.devis already in scope (L533):5b. The access log (L1319-1325), also inside
createRequestHandler, so the samedevbinding applies. One extra field, dev only, present only on a response that carried the header (so only page renders). No new line, no prod change:Tests
Server unit,
packages/server/test/seed/action-seed-unit.test.js(extend).registerActionHookssets_seedEnabled/_devModeBEFORE its_registeredidempotency guard (L606-608), so a second call in a test still flips the flags.recordSeedcalls with the same key and DIFFERENT values warn exactly once (captureconsole.warn).Object.isfails, serialized equality settles it).hash/fnand DIFFERENT args do NOT warn. This is the counterfactual for comparing on the full key: it fails if the implementer keys the check onhash/fn.dev: false, a differing duplicate does NOT warn.console.warnto throw, record two differing values for one key, and assertcollectSeedsstill returns the collector with the entry andbuildSeedScriptstill serializes it. Fails if the assertion is not in its own try/catch.buildSeedScript(new Map(), { dev: true })emits adata-webjs-dev="ok"marker block,buildSeedScript(new Map())returns'', and a non-empty collector with no opts is byte-identical to today (no marker attribute). Prod byte-identity counterfactual.seed-ssr.test.js(extend). Adev: truehandler'sGET /carriesX-Webjs-Seed: collected=1, emitted=1and the body's seed block carriesdata-webjs-dev="ok". Assert the seed key and value assertions already in the file still pass unchanged.seed-ssr-off.test.js(extend). With seeding off, a dev render's header is exactlyoff. This is the "a seeding-disabled app must not look like a seeding-broken app" requirement, and it is why the counting lives with the collector rather than behindseedingEnabled().packages/server/test/seed/seed-observability.test.js(NEW). Its own file becausemodule.registerHooksis process-global, the same reasonseed-ssr-off.test.jsis separate.dev: false) render carries NOX-Webjs-Seedheader and NOdata-webjs-devmarker. The prod-leak counterfactual.Suspense/<webjs-suspense>boundary carries the headercollected=<n>, emitted=0, streamed. In dev the body carries a marker-only block withdata-webjs-dev="streamed", and in prod it carries no block at all (today's behaviour, unchanged).export const revalidate: the second request is served from the Add a server HTML response cache with TTL and on-demand revalidation #241 HTML cache and carriesX-Webjs-Seed: html-cache.seed-hook.test.js,seed-switch.test.js: unchanged. No new config key is added, so thewebjs.*three-way lockstep (schema +WebjsConfig+ reader +KNOWN_KEYS) does not apply.Client unit,
packages/core/test/seed/action-seed-client.test.jsfirst-write-wins: an earlier seed is not clobbered by a duplicate key) intolast-write-wins: a later render's seed replaces an unconsumed earlier one, with a comment naming why the old rule protected an unreachable case.takeSeedreturns render 2's value. Fails on revert of step 1a.seedStats()reportsingested,replaced,hits,misses,pendingacross a scan plus a hit plus a miss.data-webjs-dev="ok"schedules exactly one report (stubglobalThis.requestIdleCallbackandconsole.warn); an unmarked carrier schedules none. The prod-silence counterfactual.console.warnthrows inside the report andtakeSeedstill returns its value with nothing propagating.Browser,
packages/core/test/seed/browser/action-seed-client.test.js(NEW folder + file)A unit test is necessary but not sufficient for browser-facing behaviour, and the existing fake DOM in the unit file cannot prove the real one. Against a real document (Chromium / Firefox / WebKit via web-test-runner):
<script type="application/json" id="__webjs-seeds" data-webjs-dev="ok">is ingested and REMOVED from the DOM.[data-webjs-seed]element carrier is ingested and its attribute stripped (the second carrier, which the server does not yet emit but the client reads).requestIdleCallbackpath fires exactly one report per scan batch.seedStats()matches the calls made.e2e,
test/e2e/dev-seed-observability.test.mjs(NEW) +test/e2e/fixtures/dev-seed-app/(NEW)Modelled on
test/e2e/dev-overlay-nav.test.mjs, which stages its fixture to a temp dir with symlinked@webjsdev/*(a worktree has nonode_modules) and runs a realwebjs dev. The existing prod-mode seeding e2e attest/e2e/e2e.test.mjsL489 stays unchanged. Gated behindWEBJS_E2E=1like its neighbours. The fixture carries a root layout, a page rendering a SHIPPING async component (a signal plus an@click, so it is not elided) that awaits a'use server'action, and a second page whose component calls that action with an argument the SSR render never used.GET /in dev responds withX-Webjs-Seed: collected=1, emitted=1./in a real browser fires NO/__webjs/action/request on hydration and logs NO[webjs] SSR action seedingwarning.[webjs] SSR action seedingwarning naming the unmatched-keys cause. This is the headline acceptance criterion driven end to end: a broken seeding path is visible without opening the network tab.Bun parity,
test/bun/(mandatory)The install mechanism is the one genuinely runtime-specific half (#529): Node's
module.registerHooksversus aBun.pluginonLoad, chosen byserverRuntime()inregisterActionHooks(L610-616). A facade regression on one runtime leaves the other green, and the COUNT is a direct function of whether the facade ran, so this is exactly the assertion that has to run on both.test/bun/seed.mjs(extend). Keep every existing assertion. Add acreateRequestHandler({ appDir, dev: true })pass assertingX-Webjs-Seed: collected=1, emitted=1anddata-webjs-dev="ok"in the body. A Bun-side facade regression then readscollected=0on Bun andcollected=1on Node.test/bun/seed.test.mjs: unchanged (it only imports the script under whichever runtime runs the suite).test/bun/action-seed-circular.test.mjs: unchanged, re-run as a guard that the Circular re-export between two use-server modules crashes at load #1208 circular-load property survives thebuildSeedScriptsignature change.recordSeed), so it is proven once in the Node unit test rather than duplicated.node scripts/run-bun-tests.js, plusnode test/bun/seed.mjsandbun test/bun/seed.mjs.Layers that do NOT apply
test/examples/*/smoke/*): the scaffold gallery demos no seeding surface and this adds no API an app writes.webjs checkrules: nothing here is a correctness rule an app could violate in source.webjs doctor: see the Design section. No check, noDOCTOR_CODESentry.webjs.*key is added.Docs
AGENTS.mdL271 (the seeding sentences closing the async-render paragraph)X-Webjs-Seed, the access-logseedfield, the browser warning). Keep the edit inside that sentence run: #1307 and #1308 edit other sections of this file..agents/skills/webjs/references/data-and-actions.md### SSR action seedingsection as a pure APPEND-shaped insertion (#1307 also edits this file): what it is, the correctness boundary verbatim from the Design section, the dev observability, the streamed-page exception, the note that a seed emitted for a component that then ELIDES is never consumed (visible as a non-zeropendinginseedStats()), and theseedkill switch.packages/server/AGENTS.md(module map,action-seed.jsrow)registerActionHooks({ seed, dev }), the dev determinism assertion, thebuildSeedScript(collector, { dev, reason })marker, and theX-Webjs-Seedheader.packages/core/AGENTS.md(module map,action-seed-client.jsrow)seedStats(), the last-write-wins ingest rule, and the marker-driven dev report.website/app/docs/configuration/page.ts<h2>SSR action seeding</h2>inserted immediately BEFORE<h2>Request limits & server timeouts</h2>(L132), documenting"webjs": { "seed": false }andWEBJS_SEED=0plus the dev header.WEBJS_SEEDis absent from this page today (verified). Landmine: #1308 adds theelideopt-out to this same page. Keep this a pure insertion at that one anchor; if #1308 lands first, insert after its section.website/app/docs/data-fetching/page.tsL68-70website/app/docs/server-actions/page.tsL205-206blog/ssr-action-seeding-no-refetch.mdwebjs-blog-writeskill; leave it alone.packages/cli/lib/create.js,templates/gallery/)create.jsL664-674 copies the repo-root.agents/skills/webjs/.Run
webjs checkandwebjs doctoroverwebsiteandexamples/blogafter the docs edits (the requiredconventionsCI job runsdoctorover both, #1257).Acceptance criteria
X-Webjs-Seedis set on every dev SSR page response, withoffwhen seeding is disabled,html-cacheon a Add a server HTML response cache with TTL and on-demand revalidation #241 cache hit,collected=<m>, emitted=<n>on a buffered render, andcollected=<m>, emitted=0, streamedon a streamed oneseedfield, adding no new log lineX-Webjs-Seedheader, nodata-webjs-devmarker, and no console output reaches production, and prod HTML is byte-identical to HEAD (asserted, not assumed)#__webjs-seedsblock and per-element[data-webjs-seed]) and fires on a soft navigation as well as the initial loadprocess.env.NODE_ENV, which is a compile-time constant in the built core bundleseedStats()is exported from@webjsdev/core, declared in the.d.tsoverlay, and reportsingested/replaced/hits/misses/pendinghash/fn/argsKey, and never warns for the same function called with different argsconsole.warnin the determinism path still records the seed and still emits the block, proven by a fault-injection testnode test/bun/seed.mjsandbun test/bun/seed.mjsboth green)seedkill switch on theconfigurationpageOut of scope
data-webjs-seedcarriers, or seeding streamed regions at all. The client already reads that carrier and the server has never written one. Making a streamed boundary carry its own seed is a real feature and a separate task; this issue only makes the current gap visible and names it in the report.pendinginseedStats()is this issue's whole contribution there.webjs doctorcheck or a newDOCTOR_CODESentry. Decided against with reasons in the Design section.webjs.*config key. The reporting is dev-only and needs no switch, so nothing touches the schema /WebjsConfig/ reader lockstep.seeddefault. Any of those would break the feat: seed SSR action results into hydration so async render does not re-fetch (follow-up to #469) #472 contract this issue exists to observe.blog/ssr-action-seeding-no-refetch.md.AGENTS.md,website/app/docs/configuration/page.ts, or.agents/skills/webjs/references/data-and-actions.mdthat this issue does not own. feat: resolve form-submitter boundness in webjs check and make the residual loud #1307 and feat: make the elision verdict inspectable and provable per app #1308 are being planned in parallel against the same three files. Code surfaces are disjoint; keep the doc diffs confined to the anchors named above so all three merge cleanly.