Skip to content

feat: make SSR action seeding observable and assert determinism in dev #1309

Description

@vivek7405

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:

  1. 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.
  2. The install mechanism differs per runtime (Make SSR action-result seeding (#472) work on Bun via a Bun-native facade #529). Node uses module.registerHooks, Bun uses a Bun.plugin onLoad, selected by serverRuntime() in registerActionHooks (L605-630). A regression on one runtime leaves the other green.
  3. 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.

Header values, decided:

Situation Value
Seeding switched off (seedingEnabled() false) off
Served from the #241 HTML cache html-cache
Normal buffered render collected=<m>, emitted=<n>
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

typeof process<"u"&&process.env||cn(`fallback:${e}`, ...)

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).
 */
function ingest(raw, el, cleanup) {
  if (raw) {
    try {
      const obj = parse(raw);
      if (obj && typeof obj === 'object') {
        for (const k in obj) if (!seeds.has(k)) seeds.set(k, obj[k]);
      }
    } catch {
      // Malformed payload: ignore, the stub re-fetches.
    }
  }
  if (cleanup) cleanup();
  else el.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)`.
 */
function ingest(raw, el, cleanup) {
  if (raw) {
    try {
      const obj = parse(raw);
      if (obj && typeof obj === 'object') {
        for (const k in obj) {
          if (seeds.has(k)) stats.replaced++;
          else stats.ingested++;
          seeds.set(k, obj[k]);
        }
      }
    } catch {
      // Malformed payload: ignore, the stub re-fetches.
    }
  }
  if (cleanup) cleanup();
  else el.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`).
 */
const stats = { ingested: 0, replaced: 0, hits: 0, misses: 0 };

/** `null` in prod. In dev, the `data-webjs-dev` value the server stamped. */
let devMarker = null;
/** One scheduled report at a time; each report covers the delta since the last. */
let reportScheduled = false;
let lastReport = { 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 (const el of scope.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 */
function noteDevMarker(v) {
  if (typeof v === '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.
 */
function scheduleSeedReport() {
  if (!devMarker || reportScheduled) return;
  reportScheduled = true;
  const run = () => {
    reportScheduled = false;
    try { reportSeeds(); } catch { /* diagnostics never break a page */ }
  };
  try {
    if (typeof requestIdleCallback === 'function') requestIdleCallback(run, { timeout: 1000 });
    else setTimeout(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.
 */
function reportSeeds() {
  const hits = stats.hits - lastReport.hits;
  const misses = stats.misses - lastReport.misses;
  const ingested = stats.ingested - lastReport.ingested;
  lastReport = { hits: stats.hits, misses: stats.misses, ingested: stats.ingested };
  if (misses === 0) return;
  const cause = 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 }}
 */
export function seedStats() {
  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:

  _seedEnabled = opts.seed !== false;
  _devMode = opts.dev === true;

Update the JSDoc @param from {{ seed?: boolean }} to {{ seed?: boolean, dev?: boolean }}.

3b. The determinism assertion in recordSeed (L189-201). Today:

async function recordSeed(collector, file, fnName, args, value) {
  if (isStreamable(value)) return;
  try {
    const hash = await actionFileHash(file);
    const argsKey = await stringify(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.

async function recordSeed(collector, file, fnName, args, value) {
  if (isStreamable(value)) return;
  try {
    const hash = await actionFileHash(file);
    const argsKey = await stringify(args);
    const key = `${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 { await assertDeterministic(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 = new Set();

/**
 * 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.
 */
async function assertDeterministic(prev, next, hash, fnName) {
  if (Object.is(prev, next)) return;
  const id = `${hash}/${fnName}`;
  if (_nonDeterministic.has(id)) return;
  if ((await stringify(prev)) === (await stringify(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>}
 */
export async function buildSeedScript(collector, opts = {}) {
  const dev = opts.dev === true;
  if ((!collector || collector.size === 0) && !dev) return '';
  try {
    const obj = {};
    if (collector) for (const [k, v] of collector) obj[k] = v;
    const payload = await stringify(obj);
    const safe = payload
      .replace(/</g, '\\u003c')
      // ... the remaining escapes, unchanged ...
    const marker = 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

4a. The emit block (L284-294). Today:

    let outBody = streamBody;
    if (seedCollector && suspenseCtx.pending.length === 0) {
      const seedScript = await buildSeedScript(seedCollector);
      if (seedScript) outBody = streamBody + seedScript;
    }

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.

    let outBody = streamBody;
    let seedHeader = 'off';
    const streamed = 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) {
        const marker = await buildSeedScript(new Map(), { dev: true, reason: 'streamed' });
        if (marker) outBody = streamBody + marker;
      }
    } else if (seedCollector) {
      const seedScript = await buildSeedScript(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:

        const hit = await readHtmlCache(url);
        if (hit) return cachedHtmlResponse(hit, opts.req, url);

After:

        const hit = await readHtmlCache(url);
        if (hit) {
          const cached = 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');
          return cached;
        }

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):

-  await registerActionHooks({ seed: await readSeedEnabled(appDir) });
+  await registerActionHooks({ seed: await readSeedEnabled(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 {
          const seed = 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).
  • A page with 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 carries X-Webjs-Seed: html-cache.

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.
  • 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 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.

  1. GET / in dev responds with X-Webjs-Seed: collected=1, emitted=1.
  2. Loading / in a real browser fires NO /__webjs/action/ request on hydration and logs NO [webjs] SSR action seeding warning.
  3. 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.plugin onLoad, 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).
  • 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 the buildSeedScript signature change.
  • 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.
.agents/skills/webjs/references/data-and-actions.md 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.
packages/server/AGENTS.md (module map, action-seed.js row) Add registerActionHooks({ seed, dev }), the dev determinism assertion, the buildSeedScript(collector, { dev, reason }) marker, and the X-Webjs-Seed header.
packages/core/AGENTS.md (module map, action-seed-client.js row) 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 &amp; 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.
Scaffold (packages/cli/lib/create.js, templates/gallery/) 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.
  • Changing the key format, the wire, the facade, or the seed default. 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.
  • Editing blog/ssr-action-seeding-no-refetch.md.
  • Sections of AGENTS.md, website/app/docs/configuration/page.ts, or .agents/skills/webjs/references/data-and-actions.md that 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.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

Status
In progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions