From a3c19f280e5172cc2d25085c9b570f9fac5187c7 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 22:00:55 +0530 Subject: [PATCH 01/18] feat: make SSR action seeding observable in dev A seed miss is indistinguishable from a hit from the outside, so a refactor that breaks seeding for a whole 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. Dev now reports it. `X-Webjs-Seed` carries `off` / `html-cache` / `collected=, emitted=` / `... streamed`, folded into the existing access log line as a `seed` field. The browser logs one warning per page view when a hydration action call missed, naming which of the three causes applies. That gate comes from a server-stamped `data-webjs-dev` marker, never from `process.env.NODE_ENV`, which esbuild folds to a constant in the built core bundle and `publicEnvShim` then inverts. `ingest` also flips to last-write-wins. First-write-wins protected a case that cannot occur (a hit deletes its key), and its real effect was to hand a component a value from a render no longer on screen after a soft nav. Refs #1309 --- packages/core/index-browser.js | 2 +- packages/core/index.d.ts | 2 +- packages/core/index.js | 2 +- packages/core/src/action-seed-client.d.ts | 13 ++ packages/core/src/action-seed-client.js | 128 ++++++++++++++++-- .../core/test/seed/action-seed-client.test.js | 19 ++- packages/server/src/action-seed.js | 66 ++++++++- packages/server/src/dev.js | 8 +- packages/server/src/ssr.js | 39 +++++- packages/server/test/seed/seed-ssr.test.js | 5 +- 10 files changed, 259 insertions(+), 25 deletions(-) diff --git a/packages/core/index-browser.js b/packages/core/index-browser.js index ec8e45459..191771cc5 100644 --- a/packages/core/index-browser.js +++ b/packages/core/index-browser.js @@ -64,7 +64,7 @@ export { optimistic } from './src/optimistic.js'; // SSR action-seed consumer (#472): the generated RPC stub reads a seed on its // first call so async-render hydration does not re-fetch the SSR'd data. -export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js'; +export { takeSeed, scanSeeds, seedStats, SEED_MISS } from './src/action-seed-client.js'; // Client tag-cache coordinator for HTTP-verb actions (#488): tag-based // browser-cache eviction after a mutation. Inert server-side. export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js'; diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index 5a3d79445..1e21ff301 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -108,7 +108,7 @@ export { signal, computed, effect, batch, isSignal, Signal } from './src/signal. export { cspNonce, setCspNonceProvider } from './src/csp-nonce.js'; export { asset, setAssetUrlProvider } from './src/asset-url.js'; export { FORM_ACTION_FIELD, FORM_ACTION_ID_KEY, setFormActionResolver } from './src/form-action.js'; -export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js'; +export { takeSeed, scanSeeds, seedStats, SEED_MISS } from './src/action-seed-client.js'; export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js'; // Client action-abort plumbing (#492): a superseded async render aborts its // in-flight action fetches. Inert server-side. diff --git a/packages/core/index.js b/packages/core/index.js index ed8e522d1..2254293fb 100644 --- a/packages/core/index.js +++ b/packages/core/index.js @@ -40,7 +40,7 @@ export { optimistic } from './src/optimistic.js'; // SSR action-seed consumer (#472): the generated RPC stub reads a seed on its // first call so async-render hydration does not re-fetch. Inert server-side. -export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js'; +export { takeSeed, scanSeeds, seedStats, SEED_MISS } from './src/action-seed-client.js'; // Client tag-cache coordinator for HTTP-verb actions (#488): tag-based // browser-cache eviction after a mutation. Inert server-side. export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js'; diff --git a/packages/core/src/action-seed-client.d.ts b/packages/core/src/action-seed-client.d.ts index 5925b61a0..742ed927d 100644 --- a/packages/core/src/action-seed-client.d.ts +++ b/packages/core/src/action-seed-client.d.ts @@ -15,3 +15,16 @@ export function scanSeeds(root?: ParentNode): void; * Keyed `hash/fn/argsKey`; the first call lazily scans the initial document. */ export function takeSeed(hash: string, fnName: string, argsKey: string): unknown; + +/** + * The cumulative seed counters for this page session (#1309). `ingested` and + * `replaced` are what `scanSeeds` merged; `hits` and `misses` are what the + * generated RPC stubs asked for; `pending` is what is still unconsumed. + */ +export function seedStats(): { + ingested: number; + replaced: number; + hits: number; + misses: number; + pending: number; +}; diff --git a/packages/core/src/action-seed-client.js b/packages/core/src/action-seed-client.js index a8499dc98..68d0d4420 100644 --- a/packages/core/src/action-seed-client.js +++ b/packages/core/src/action-seed-client.js @@ -3,8 +3,10 @@ * * The server (`@webjsdev/server`'s `action-seed.js`) serializes each action * result invoked during SSR into a ``; + // Attribute-safe by construction: `reason` is a framework literal ('ok' / + // 'streamed'), never app input, and is emitted only in dev. + const marker = dev ? ` data-webjs-dev="${opts.reason || 'ok'}"` : ''; + return ``; } catch { return ''; } diff --git a/packages/server/src/dev.js b/packages/server/src/dev.js index f1120d566..513fc7138 100644 --- a/packages/server/src/dev.js +++ b/packages/server/src/dev.js @@ -650,7 +650,7 @@ export async function createRequestHandler(opts) { // `webjs.seed` switch. Action identity (#1155) rides the same hook, and it is // what a bound `
` resolves through, so gating the hook // on seeding would mean `webjs.seed: false` silently broke every no-JS form. - await registerActionHooks({ seed: await readSeedEnabled(appDir) }); + await registerActionHooks({ seed: await readSeedEnabled(appDir), dev }); // When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a @@ -1316,12 +1316,18 @@ export async function createRequestHandler(opts) { // root-mounted `/__webjs/*`. The logged `path` stays the RAW client URL. if (shouldAccessLog(headerPathname)) { try { + // #1309: fold the dev-only seeding counters into the ONE access-log + // line rather than adding a second one. Present only on a response + // that carried the header, so only page renders gain the field and + // production is unchanged. + 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 */ } } diff --git a/packages/server/src/ssr.js b/packages/server/src/ssr.js index e12e1a5c8..4058890ff 100644 --- a/packages/server/src/ssr.js +++ b/packages/server/src/ssr.js @@ -57,7 +57,15 @@ export async function ssrPage(route, params, url, opts) { revalidateSeconds = readRevalidate(pageMod); if (revalidateSeconds !== null) { const hit = await readHtmlCache(url); - if (hit) return cachedHtmlResponse(hit, opts.req, url); + if (hit) { + const cached = cachedHtmlResponse(hit, opts.req, url); + // A cache hit returns before any seed work runs, so it would otherwise + // report nothing at all, which reads as "seeding is broken" when it is + // really the cache answering (#1309). The seed block rides INSIDE the + // cached bytes, so the seeds are exactly as fresh as the HTML. + if (opts.dev) cached.headers.set('X-Webjs-Seed', 'html-cache'); + return cached; + } } } catch { // A load / store failure falls through to a normal fresh render: the @@ -288,9 +296,29 @@ export async function ssrPage(route, params, url, opts) { // block (those slow regions keep the stale-while-revalidate refetch). An // empty collector yields '' so the output stays byte-identical. let outBody = streamBody; - if (seedCollector && suspenseCtx.pending.length === 0) { - const seedScript = await buildSeedScript(seedCollector); + // Dev-only seeding diagnostics (#1309). `off` (seeding disabled) is kept + // DISTINCT from `collected=0` on purpose: the counting lives with the + // collector rather than behind the seed gate, so a seeding-DISABLED app + // never looks like a seeding-BROKEN one. + 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 action call went to the network. + seedHeader = `collected=${seedCollector.size}, emitted=0, streamed`; + if (opts.dev) { + const marker = await buildSeedScript(null, { 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 a completely invisible + // failure. + seedHeader = `collected=${seedCollector.size}, emitted=${seedScript ? seedCollector.size : 0}`; } const res = streamingHtmlResponse( prefix, @@ -307,6 +335,11 @@ export async function ssrPage(route, params, url, opts) { nonce, opts.dev, ); + // 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); // REDUCED response (#1009): the X-Webjs-Have short-circuit omitted the // outer-layout chrome, so these bytes are only valid for a request that // sent a matching `have`. Left shared-cacheable, a CDN edge could store the diff --git a/packages/server/test/seed/seed-ssr.test.js b/packages/server/test/seed/seed-ssr.test.js index b6344f3ec..75baa222f 100644 --- a/packages/server/test/seed/seed-ssr.test.js +++ b/packages/server/test/seed/seed-ssr.test.js @@ -95,7 +95,10 @@ test('the SSR HTML carries the seed payload, keyed exactly as the stub looks it assert.match(html, /data-y="2020"/, 'rich Date resolved server-side'); // The seed script is present and keyed by hashFile(actionPath)/getUser/[1]. - const m = html.match(/`; } catch { + // The serializer threw, so the whole block is dropped. This is the ONE + // failure the counting exists to expose (`collected` above `emitted`), and + // in prod it stays silent as before. In DEV emit a marker-only block so the + // browser can name it: with no marker at all the client opens no report + // window, so every missed hydration call on the page prints nothing, which + // is precisely the invisible failure this feature removes. + if (dev) { + try { + return ``; + } catch { /* fall through to the silent drop */ } + } return ''; } } diff --git a/packages/server/test/seed/action-seed-unit.test.js b/packages/server/test/seed/action-seed-unit.test.js index 3a7fed13d..c48308482 100644 --- a/packages/server/test/seed/action-seed-unit.test.js +++ b/packages/server/test/seed/action-seed-unit.test.js @@ -486,3 +486,16 @@ test('buildSeedScript: prod output is byte-identical to before the marker', asyn assert.equal(await buildSeedScript(collector, { reason: 'streamed' }), bare); assert.ok(!bare.includes('data-webjs-dev'), 'no marker attribute in prod'); }); + +test('dev marks a serializer DROP so the browser can name it; prod stays silent', async () => { + // The one failure the counting exists to expose. A value the wire cannot carry + // makes `stringify` throw and the whole block is dropped; without a marker the + // client opens no report window and the page prints nothing at all. + const collector = new Map(); + // A function is not serializer-safe, so `stringify` throws and the whole block + // is dropped, taking every OTHER seed on the page with it. + collector.set('h/f/[1]', () => {}); + const devOut = await buildSeedScript(collector, { dev: true }); + assert.match(devOut, /data-webjs-dev="drop"/, 'dev names the drop'); + assert.equal(await buildSeedScript(collector), '', 'prod is byte-identical to before'); +}); diff --git a/website/app/docs/data-fetching/page.ts b/website/app/docs/data-fetching/page.ts index 37fe5a0ff..3b8aebcd8 100644 --- a/website/app/docs/data-fetching/page.ts +++ b/website/app/docs/data-fetching/page.ts @@ -67,7 +67,7 @@ class Report extends WebComponent {

A shipping async component does not re-fetch on hydration (seeding)

When an async component DOES ship (it has an interactivity signal, so it cannot be elided), WebJs still avoids the redundant hydration fetch. Each 'use server' action result invoked during the SSR render is serialized into the page, and the generated RPC stub reads that seed on its first client call. So const u = await getUser(this.id) runs once, on the server, and the client's first render reuses the result with no network round-trip. A later refetch (a prop or signal change, a new argument) misses the seed and goes to the server as normal.

-

What a hit guarantees. 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 development: 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. Keep an action deterministic for a given argument list.

+

What a hit guarantees. 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. Navigating to another page evicts whatever the previous one left unconsumed, so a departed render's value is never served. 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 development: 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. Keep an action deterministic for a given argument list.

It is automatic and needs no code: the same async render() you already wrote. There is no source transform and no build step (the capture is a transparent server-side facade over the action module), so what you write is what you see in the browser source tab. It is on by default; disable it with "webjs": { "seed": false } in package.json or WEBJS_SEED=0, in which case the client re-fetches on hydration (the stale-while-revalidate default hides the flicker). Seeding also needs a fully buffered render: a page carrying a Suspense or <webjs-suspense> boundary emits no seed block at all, so every action on that page calls out on hydration, not just the streamed region.

Telling whether it is working. A miss looks exactly like a hit from the outside, so development surfaces it two ways. Every dev page response carries an X-Webjs-Seed header (collected=<m>, emitted=<n> on a normal render, plus off, html-cache, and ... streamed variants), folded into the access log as a seed field. And the browser logs one warning per page view when a hydration call missed, naming the cause: the page streams, the page carried no seeds at all, or it carried seeds but not for these calls (usually an argument the server render never used, such as one computed from browser-only state). Silence means every call hit. Nothing of this reaches production. See Configuration.

diff --git a/website/app/docs/server-actions/page.ts b/website/app/docs/server-actions/page.ts index 175b49d97..17802a164 100644 --- a/website/app/docs/server-actions/page.ts +++ b/website/app/docs/server-actions/page.ts @@ -203,7 +203,7 @@ for await (const chunk of await streamTokens(8)) {

An action reads the request's AbortSignal via actionSignal() (from @webjsdev/server) to stop work on a client disconnect or abort. On the client, a superseded async render() automatically ABORTS the previous render's in-flight action fetch, so a fast-typing user does not pile up stale requests. Outside an action, actionSignal() returns a never-aborting signal, so a direct server-to-server call stays safe.

No re-fetch on hydration (SSR seeding)

-

Each server-action result invoked during a (non-streamed) SSR render is serialized into the page, and the generated stub reads that seed on its FIRST client call, so a shipping component does not re-issue the RPC on hydration. A later refetch or argument change still goes to the network. The seed is keyed by action hash plus function plus serialized arguments, consumed once, and fail-open (a miss degrades to a normal RPC). A hit returns the value the SSR render that produced this page computed for that exact key, so it cannot disagree with the HTML on screen; the one exception, an action returning two different results for the same arguments in one render, is warned about in development. Because a miss is otherwise invisible, dev also reports the seed counts on an X-Webjs-Seed header and logs one browser warning per page view when a hydration call missed. It is on by default; opt out with "webjs": { "seed": false } or WEBJS_SEED=0.

+

Each server-action result invoked during a (non-streamed) SSR render is serialized into the page, and the generated stub reads that seed on its FIRST client call, so a shipping component does not re-issue the RPC on hydration. A later refetch or argument change still goes to the network. The seed is keyed by action hash plus function plus serialized arguments, consumed once, and fail-open (a miss degrades to a normal RPC). A hit returns the value the SSR render that produced this page computed for that exact key, so it cannot disagree with the HTML on screen (a navigation evicts whatever the previous page left unconsumed); the one exception, an action returning two different results for the same arguments in one render, is warned about in development. Because a miss is otherwise invisible, dev also reports the seed counts on an X-Webjs-Seed header and logs one browser warning per page view when a hydration call missed. It is on by default; opt out with "webjs": { "seed": false } or WEBJS_SEED=0.

CSRF Protection

Every mutating server action RPC call (POST / PUT / PATCH / DELETE) is protected against Cross-Site Request Forgery by a cross-origin check, the model Remix 3 and Go 1.25 use. A GET action is CSRF-exempt (as noted above), since it is a read and does not mutate state. The check works as follows:

From a52cadc9fe5cd433f082d0f36e8e28192e87c2b8 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 00:24:34 +0530 Subject: [PATCH 13/18] fix: a dropped block must not count as emitted, and sync the cause list everywhere The drop marker added in the previous commit is a truthy string, and the header inferred emitted from exactly that, so in dev a serializer drop reported collected=N, emitted=N. Zero seeds shipped and the header claimed all of them did, on the one failure these counts exist to expose. It also falsified the client's own warning text, which tells the developer the response reports collected above emitted. The drop block is a named export now and the emitter excludes it, with a header-level regression test; the gap that let this through was that the drop was only ever tested through buildSeedScript in isolation, never through the response. The rest is sync. Narrowing the client to provable causes changed the contract in five prose surfaces that still listed the old three causes and still said silence means every call hit, and two comments plus a test comment still argued from paths the store eviction forecloses. --- AGENTS.md | 2 +- packages/core/AGENTS.md | 2 +- packages/core/src/action-seed-client.js | 25 ++++++---- .../core/test/seed/action-seed-client.test.js | 49 +++++++++---------- packages/server/AGENTS.md | 2 +- packages/server/src/action-seed.js | 17 ++++--- packages/server/src/ssr.js | 10 ++-- .../test/seed/seed-observability.test.js | 27 ++++++++++ website/app/docs/configuration/page.ts | 2 +- website/app/docs/data-fetching/page.ts | 2 +- 10 files changed, 89 insertions(+), 49 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 55cdf4561..6ef33f540 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -268,7 +268,7 @@ MyThing.register('my-thing'); **Lifecycle (lit-aligned), in order:** `shouldUpdate`, `willUpdate`, controllers' `hostUpdate()`, `update` (calls `render()` + commits), controllers' `hostUpdated()`, `firstUpdated`, `updated`, `updateComplete`, each receiving a `changedProperties` Map. **SSR runs only the constructor, attribute application, the pre-render hooks (`willUpdate` / `hostUpdate`), `reflect: true` reflection, and `render()`; it does NOT call `connectedCallback`, `firstUpdated`, `updated`, or any browser-only hook.** So defaults for first paint go in the constructor; browser-only data (localStorage, viewport, `navigator.*`) goes in `connectedCallback` writing a signal; server-known data arrives via the page function. Never ship a placeholder first paint that fetches in `connectedCallback`. A browser-only global in the constructor/`render()` throws at SSR (flagged by `no-browser-globals-in-render`; attribute methods and `closest()` are shimmed). -**Async render (`async render()`), bare-await data fetch (#469).** A component may write `async render() { const u = await getUser(this.id); return html\`

${u.name}

\`; }`. Writing `await` makes the function async by JS rule, and every render path awaits a promise-returning `render()` automatically (no flag). This co-locates the fetch in the leaf component (no prop-drilling). The model is decoupled into three separate concerns. (1) **SSR always blocks**, so the resolved DATA is in the first paint with no fallback markup (PE-safe, JS-off reads it). (2) **The client re-fetch default is stale-while-revalidate**: when a prop / dependency change re-runs `async render()`, the current content stays until the new render resolves (no blank, no flash). (3) **`renderFallback()` is the OPTIONAL re-fetch loading UI**, a prop-aware method shown ONLY during a client re-fetch, NEVER on the first paint, and it does NOT trigger SSR streaming. **Errors are isolated per component by default** (no user code): a thrown `await getData()` renders a component-scoped error state while siblings render, and `renderError()` optionally customizes it (dev surfaces the message, prod stays silent). `getData()` is already isomorphic (a `'use server'` action is the real function during SSR and an RPC stub on the client), so the same line works both sides. Use `async render()` for request-time-known SERVER data that should be in the first paint; keep `Task` / signals for genuinely client-only data (a `Task` shows its pending state at SSR, losing first-paint data). A **bare** async-render component (an `async render()` with no other client signal, light DOM) is **elided** like any display-only component (#474): its SSR'd HTML is the complete output, so the framework drops the module AND the redundant on-hydration re-fetch. It SHIPS only when it also carries an independent signal (an `@event`, a non-`state` reactive prop, a signal / reactive import, a lifecycle hook including `renderFallback()`, the dynamic slot READ surface (`slotchange` / `assignedNodes` / `assignedElements` / `assignedSlot`; merely RENDERING a `` does not ship, since the SSR output carries the placed children), `static shadow = true`, `static interactive = true`, cross-module observation, or a transitively-reachable interactive child). Two carve-outs always ship: `static shadow = true` (Declarative Shadow DOM attaches only during HTML parsing, so a streamed or soft-navigated shadow component needs its module to re-run `attachShadow`) and `static interactive = true` (the explicit author override that forces a ship when the analyser cannot see a component's interactivity statically, for example a dynamically-computed tag string or a `:defined` rule in an external stylesheet outside the module graph). **For SLOW data where blocking the first byte hurts, wrap the region in `` to STREAM it** (the fallback flushes on the first byte, the data streams in; multiple boundaries fetch concurrently). This is the only way to show a first-paint fallback, a deliberate choice for slow regions, and it streams progressively on soft navigation too. A throwing component inside a boundary is isolated (renders its error state, siblings stream). **The on-hydration re-fetch is itself eliminated by SSR action seeding (#472):** each `'use server'` action result invoked during a (non-streamed) SSR render is serialized into the page, and the generated RPC stub reads that seed on its first client call, so a shipping async component does NOT re-issue the RPC on hydration (a later refetch / arg-change still goes to the network). Keyed by action-hash + fn + serialized args, consume-once, fail-open (a miss degrades to a normal RPC). A hit returns the value the SSR render that produced THIS page computed for exactly that key, so it cannot disagree with the HTML on screen (a page navigation evicts whatever the outgoing page left unconsumed, in the DOM and in the store, so a departed render's value can never be served); on an HTML-cached page (#241) the seed rides inside the cached bytes and is as fresh as they are. The one shape where a hit can differ from the paint is an action returning a DIFFERENT result for the SAME arguments twice in one render (the seed carries the last, the first component painted the first), which dev warns about once per action function. Captured via a transparent server-side `'use server'` facade (no source transform, no build step; the browser source tab and on-disk files are unchanged), default on, opt out with `"webjs": { "seed": false }` or `WEBJS_SEED=0`. **A miss is otherwise invisible, so dev makes it observable (#1309):** every dev page response carries `X-Webjs-Seed` (`off` / `html-cache` / `collected=, emitted=` / `collected=, emitted=0, streamed`), folded into the access-log line as a `seed` field, and the browser logs ONE warning per page view when a hydration call missed, naming the cause (streamed page, no seeds at all, keys unmatched) and staying silent when every call hit. `seedStats()` from `@webjsdev/core` exposes the counters. Nothing reaches production: the client's dev gate is a server-stamped `data-webjs-dev` marker on the seed block, never `process.env.NODE_ENV`, which esbuild folds to a constant in the built core bundle. See `references/data-and-actions.md`. +**Async render (`async render()`), bare-await data fetch (#469).** A component may write `async render() { const u = await getUser(this.id); return html\`

${u.name}

\`; }`. Writing `await` makes the function async by JS rule, and every render path awaits a promise-returning `render()` automatically (no flag). This co-locates the fetch in the leaf component (no prop-drilling). The model is decoupled into three separate concerns. (1) **SSR always blocks**, so the resolved DATA is in the first paint with no fallback markup (PE-safe, JS-off reads it). (2) **The client re-fetch default is stale-while-revalidate**: when a prop / dependency change re-runs `async render()`, the current content stays until the new render resolves (no blank, no flash). (3) **`renderFallback()` is the OPTIONAL re-fetch loading UI**, a prop-aware method shown ONLY during a client re-fetch, NEVER on the first paint, and it does NOT trigger SSR streaming. **Errors are isolated per component by default** (no user code): a thrown `await getData()` renders a component-scoped error state while siblings render, and `renderError()` optionally customizes it (dev surfaces the message, prod stays silent). `getData()` is already isomorphic (a `'use server'` action is the real function during SSR and an RPC stub on the client), so the same line works both sides. Use `async render()` for request-time-known SERVER data that should be in the first paint; keep `Task` / signals for genuinely client-only data (a `Task` shows its pending state at SSR, losing first-paint data). A **bare** async-render component (an `async render()` with no other client signal, light DOM) is **elided** like any display-only component (#474): its SSR'd HTML is the complete output, so the framework drops the module AND the redundant on-hydration re-fetch. It SHIPS only when it also carries an independent signal (an `@event`, a non-`state` reactive prop, a signal / reactive import, a lifecycle hook including `renderFallback()`, the dynamic slot READ surface (`slotchange` / `assignedNodes` / `assignedElements` / `assignedSlot`; merely RENDERING a `` does not ship, since the SSR output carries the placed children), `static shadow = true`, `static interactive = true`, cross-module observation, or a transitively-reachable interactive child). Two carve-outs always ship: `static shadow = true` (Declarative Shadow DOM attaches only during HTML parsing, so a streamed or soft-navigated shadow component needs its module to re-run `attachShadow`) and `static interactive = true` (the explicit author override that forces a ship when the analyser cannot see a component's interactivity statically, for example a dynamically-computed tag string or a `:defined` rule in an external stylesheet outside the module graph). **For SLOW data where blocking the first byte hurts, wrap the region in `` to STREAM it** (the fallback flushes on the first byte, the data streams in; multiple boundaries fetch concurrently). This is the only way to show a first-paint fallback, a deliberate choice for slow regions, and it streams progressively on soft navigation too. A throwing component inside a boundary is isolated (renders its error state, siblings stream). **The on-hydration re-fetch is itself eliminated by SSR action seeding (#472):** each `'use server'` action result invoked during a (non-streamed) SSR render is serialized into the page, and the generated RPC stub reads that seed on its first client call, so a shipping async component does NOT re-issue the RPC on hydration (a later refetch / arg-change still goes to the network). Keyed by action-hash + fn + serialized args, consume-once, fail-open (a miss degrades to a normal RPC). A hit returns the value the SSR render that produced THIS page computed for exactly that key, so it cannot disagree with the HTML on screen (a page navigation evicts whatever the outgoing page left unconsumed, in the DOM and in the store, so a departed render's value can never be served); on an HTML-cached page (#241) the seed rides inside the cached bytes and is as fresh as they are. The one shape where a hit can differ from the paint is an action returning a DIFFERENT result for the SAME arguments twice in one render (the seed carries the last, the first component painted the first), which dev warns about once per action function. Captured via a transparent server-side `'use server'` facade (no source transform, no build step; the browser source tab and on-disk files are unchanged), default on, opt out with `"webjs": { "seed": false }` or `WEBJS_SEED=0`. **A miss is otherwise invisible, so dev makes it observable (#1309):** every dev page response carries `X-Webjs-Seed` (`off` / `html-cache` / `collected=, emitted=` / `collected=, emitted=0, streamed`), folded into the access-log line as a `seed` field, and the browser logs ONE warning per page view when a hydration call missed AND the cause is provable (a streamed page, a serializer drop, or seeds present but unmatched), staying silent otherwise, including on a page that emitted no seeds, where a miss is not evidence of a defect because a mutation / `Task` / `connectedCallback` call routes through the same lookup and could never be seeded. `seedStats()` from `@webjsdev/core` exposes the counters. Nothing reaches production: the client's dev gate is a server-stamped `data-webjs-dev` marker on the seed block, never `process.env.NODE_ENV`, which esbuild folds to a constant in the built core bundle. See `references/data-and-actions.md`. **Light DOM (default) vs Shadow DOM.** Light DOM applies global CSS and Tailwind directly (default; for Tailwind/global CSS + simple composition). Shadow DOM (`static shadow = true`) is for `static styles` scoped CSS and third-party isolation; `` works in either. **Light-DOM slots ARE the native DOM slot API (#1021, full shadow parity):** `` works identically in light and shadow DOM, so post-mount native writes are LIVE (`appendChild`, `insertBefore`, `removeChild`, `el.remove()`, `innerHTML`, `el.slot=` flips, `HTMLSlotElement.assign()`) and the reads (`assignedNodes` / `assignedElements` / `{flatten}` / `assignedSlot` / `slotchange`, with native async-coalesced timing) match. Flip `static shadow` and nothing else changes; there is NO WebJs-specific slot API. The one write that does NOT flip is `assign()`: the light-DOM version is an extension (element-bound overlay alongside name matching), while native shadow `assign()` needs `slotAssignment: 'manual'`, which WebJs does not set, so avoid `assign()` in a component meant to flip modes. A FORWARDED slot (a template forwarding `` into a nested component) projects its content on the client and through hydration (#1023): the renderer stamps each slot with its template owner (`SLOT_OWNER`, carried across SSR as `data-wj-slot-owner`) so it routes to the OUTER host that rendered it, and a layout's `${children}` inside a slotted shell keeps its named slots in sync across a soft-nav swap (#1024, the swap resyncs every own slot of the enclosing host). Four inherent gaps, all a consequence of light DOM having no shadow boundary: structural host reads (`host.children` / the `innerHTML` getter show the rendered template, not the authored children, so read slotted content with `assignedNodes()`), `assignedChild.parentNode` is the ``, `::slotted()` CSS (style slotted content with normal selectors / Tailwind), and initial-projection lifecycle timing (`firstUpdated` sees the `` element with EMPTY `assignedNodes()`, the projection lands one microtask later; read assigned content from `slotchange` or after a microtask). Live writes need the component's JS on the page, so a display-only slotted wrapper elides (its writes are inert like anything elided; force a ship with `static interactive = true` for an imperative consumer the analyser cannot see). A light-DOM component authoring custom CSS MUST prefix every class selector with its tag name (invariant 7); prefer Tailwind. **Light-DOM component hosts default to `display: block`**: a custom element is `display: inline` in plain CSS, so the framework marks every LIGHT-DOM host `data-wj-host` and injects one head rule in a low-priority cascade layer, `@layer webjs-host { :where([data-wj-host]) { display: block } }`, so a container component does not collapse; the layer keeps it overridable by any author style INCLUDING Tailwind utilities (`class="flex"`/`grid`/`hidden` win, because their layer is ordered after `webjs-host`), a `[hidden]` carve-out keeps `?hidden` working, and an inline light component opts out with `my-tag { display: inline }`. **Shadow-DOM hosts are NOT marked** (a document rule would override the shadow tree's `:host`), so a shadow component sets its host display via `:host { display: block }` in `static styles` (respected because the host is unmarked; set it for a shadow block container). See the even-grid / no-reflow layout recipes in `references/styling.md`. **Never interpolate into a component's `\``): the server emits it but the client drops the raw-text hole, so it paints at SSR then wipes to empty on hydrate. Use `static styles` or Tailwind instead (flagged by `no-interpolation-in-raw-text-element`). A page/layout, which never hydrates, may interpolate a `css` result into `