Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a3c19f2
feat: make SSR action seeding observable in dev
vivek7405 Aug 6, 2026
b8923e1
test: cover the seed dev header, marker gate, and determinism assertion
vivek7405 Aug 6, 2026
9dec1f3
test: prove the seed consumer against a real document
vivek7405 Aug 6, 2026
b946a08
test: drive the seed dev report end to end through webjs dev
vivek7405 Aug 6, 2026
6582310
docs: document the seeding boundary and its dev diagnostics
vivek7405 Aug 6, 2026
08c2b07
fix: measure the seed report over the hydration window, not since the…
vivek7405 Aug 6, 2026
0647a4b
fix: count replaced seeds as merged, and bind the cause to its own wi…
vivek7405 Aug 6, 2026
91d6dc5
fix: a scan closes the previous report window instead of joining it
vivek7405 Aug 6, 2026
abd2aab
fix: drain the outgoing page's seed block before ingesting the incomi…
vivek7405 Aug 6, 2026
7818d94
fix: a markerless swap reports for the page it lands on, and never dr…
vivek7405 Aug 6, 2026
c745b10
fix: a frame swap is not a page navigation, and a departed page's see…
vivek7405 Aug 6, 2026
6b43c7c
fix: a navigation evicts the outgoing page's seeds, and the client on…
vivek7405 Aug 6, 2026
a52cadc
fix: a dropped block must not count as emitted, and sync the cause li…
vivek7405 Aug 6, 2026
74749f9
fix: a frame swap discards what it parses, and the drop cause gets it…
vivek7405 Aug 6, 2026
f1da17e
docs: describe the frame fallthrough as it actually behaves
vivek7405 Aug 6, 2026
949c4f4
fix: warn only on a miss the client can prove is a key mismatch
vivek7405 Aug 6, 2026
d30a3eb
fix: ingest a response's seeds only once the swap commits to applying it
vivek7405 Aug 6, 2026
5b42329
fix: the reported count and the claim it makes must agree
vivek7405 Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/webjs/references/built-ins.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ Two guarantees worth knowing. A result that could not check (a network or toolch

Wired at the single response funnel, covering pages, routes, actions, and assets uniformly.

- **Access log.** One structured `info` line per handled request (`method`, `path`, `status`, `durationMs`, `requestId`). Never logs bodies or secrets; framework `/__webjs/*` traffic is suppressed.
- **Access log.** One structured `info` line per handled request (`method`, `path`, `status`, `durationMs`, `requestId`, plus a dev-only `seed` field on a page render carrying the SSR action-seeding counters, #1309). Never logs bodies or secrets; framework `/__webjs/*` traffic is suppressed.
- **Request id.** Each request gets a `crypto.randomUUID()` correlation id, set as `X-Request-Id` (honoring a trusted inbound one) and readable server-side with `requestId()` from `@webjsdev/server` (returns `null` outside a request scope).
- **`onError` hook.** Register via `createRequestHandler({ onError })` or `startServer({ onError })`. Called with `(error, { request, requestId, phase })` on any caught pipeline error, before the sanitized response is sent. Best-effort (a throwing hook is ignored), purely additive (the sanitized 500 / action digest is unchanged). Point it at Sentry or an APM.

Expand Down
52 changes: 52 additions & 0 deletions .agents/skills/webjs/references/data-and-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,55 @@ import { posts } from '#db/schema.server.ts';
```

Keep the wire shape in a browser-safe `modules/<feature>/types.ts` with NO runtime import from a `.server.ts` file or from `db/`. Define a hand-written DTO, or a type-only derivation (`import type { Post } ...; export type PostFormatted = Omit<Post, 'createdAt'> & { createdAt: string }`). Never `export *` or a value re-export from a `.server.ts` in `types.ts`; that carries the runtime table bindings and breaks any component importing the types. Full reference at https://webjs.dev/docs.

## SSR action seeding, and how to tell it is working

When a shipping component's `async render()` awaits an action during SSR, WebJs serializes that result into the page and the generated RPC stub reads it on its FIRST client call. So `const u = await getUser(this.id)` runs once, on the server, and hydration reuses the result with no network round-trip.

**You write nothing for this.** It is automatic, on by default, and there is no API to call. The only thing you can do is break it, so the section below is about noticing when you have.

### The correctness boundary

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. A page navigation evicts whatever the outgoing page left unconsumed, both the block still in the DOM and anything already ingested from it, 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 dev: **an action that returns a DIFFERENT result for the SAME arguments twice in one render.** The seed carries the last result while the first component painted the first one. So keep an action deterministic for a given argument list. A counter, a `Math.random()`, a `new Date()` in the return value, or a read of mutable module state all break that rule, and dev prints:

```
[webjs] SSR action seeding: "getUser" returned two DIFFERENT results for the SAME arguments during one render. ...
```

The fix is to make the action deterministic, or to move the varying part into an argument so the two calls get different keys.

### Reading the dev diagnostics

A miss is invisible from the outside: the page still renders correctly, it just pays a round-trip per async component on every first load. Two channels make it visible in dev, and neither exists in production.

**Server side, per request.** The `X-Webjs-Seed` response header, also folded into the dev access-log line as a `seed` field:

| Value | What it means |
|---|---|
| `off` | Seeding is switched off (`"webjs": { "seed": false }` or `WEBJS_SEED=0`). Not a defect. |
| `html-cache` | The #241 HTML response cache answered. The seeds rode inside the cached bytes. |
| `collected=3, emitted=3` | Healthy. Three action results were captured and all three reached the page. |
| `collected=3, emitted=0` | The serializer threw and dropped the whole block. Something in a returned value is not serializer-safe. |
| `collected=3, emitted=0, streamed` | The page streams, so nothing could be emitted (see below). |

Check it with `curl -sSI localhost:3000/` or in the network tab.

**Browser side, per page view.** One `console.warn` at the first idle after hydration, and only when a call missed AND the client can be certain why. It stays silent otherwise, including on a page that emitted no seeds at all: every action call routes through the seed lookup, including ones that were never SSR-invoked and never could have been seeded (a mutation, a `Task` autorun, a `connectedCallback` read), so a miss there is not evidence of a defect. That case is the server header's job, where `collected=0` is unambiguous. The line names one of these:

- *"This page streams"*, so no seeds could be emitted. Expected, not a bug (see below).
- *"The page's seeds could not be serialized."* Something an action returned is not serializer-safe, so the whole block was dropped. The response header shows `collected` above `emitted` for the same reason.
- *"The page seeded these actions under DIFFERENT arguments."* The key is `hash(action file) / function name / serialized arguments`, so the client asked with an argument the SSR render never used. Common cause: the component computes its argument from browser-only state (a `localStorage` read, a `connectedCallback` assignment), which the server render could not have known. A miss on an action the page never seeded at all is NOT reported, because a mutation or a client-only read routes through the same lookup and could never have been seeded.

A miss AFTER hydration is correct and is not reported: the seed is consume-once, so a deliberate refetch or an argument change is supposed to go to the network.

`seedStats()` from `@webjsdev/core` returns `{ ingested, replaced, hits, misses, keyMisses, pending }` (`keyMisses` being the provable subset of `misses`, a call for an action the page seeded under other arguments) if you want to assert this in a browser test or read it from the console. A non-zero `pending` at rest usually means the seeding component ELIDED, so its module never shipped and nothing on the client was ever going to consume the seed. `pending` covers the page you are on: a page navigation evicts whatever the outgoing page left unconsumed, both the block still sitting in the DOM and anything already ingested from it, since those values belong to a render no longer on screen.

### The streamed-page exception

A page carrying a `Suspense` or `<webjs-suspense>` boundary emits NO seed block at all, not just none for the streamed region: a streamed render's deferred boundaries resolve after the first flush, so their results cannot ride the block. Every action call on that page goes to the network on hydration. That is a real trade, so make it deliberately: reach for a streaming boundary when a slow region would otherwise block the first byte, and leave a fast page buffered so it seeds.

### Switching it off

`"webjs": { "seed": false }` in `package.json`, or `WEBJS_SEED=0`. The client then re-fetches on hydration exactly as it did before the feature, and stale-while-revalidate hides the flicker. Turn it off only to isolate a problem; there is no reason to ship with it off.
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,16 @@ jobs:
env:
WEBJS_E2E: '1'
run: node --test test/e2e/dev-overlay-nav.test.mjs
# Dev observability for SSR action seeding (#1309): spawns `webjs dev`
# against a fixture app and drives a real browser, because the headline
# criterion is a browser fact. Whether a hydrating component re-issued the
# RPC is a network observation, and the console line fires on an idle
# callback after a real hydration pass; neither is reachable from a unit
# test.
- name: Run dev-seed-observability e2e (#1309)
env:
WEBJS_E2E: '1'
run: node --test test/e2e/dev-seed-observability.test.mjs
# Touch-emulation e2e for interactive Tier-2 ui components (#745/#747):
# boots the site serving the gallery and taps hover-card / dropdown-submenu / sonner
# under a Chromium iPhone context (faithful touch events, no real device).
Expand Down
Loading
Loading