@webjsdev/core 0.7.47 → 0.7.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index-browser.js CHANGED
@@ -8,9 +8,10 @@
8
8
  * Stripped:
9
9
  * - `renderToString`, `renderToStream` (server SSR pipeline; reach
10
10
  * for `@webjsdev/core/server` if you need them in a Node test).
11
- * - `setCspNonceProvider` (server-side wiring called by
12
- * `@webjsdev/server`'s context module; `cspNonce()` stays
13
- * because layouts can call it).
11
+ * - `setCspNonceProvider`, `setAssetUrlProvider` and
12
+ * `setFormActionResolver` (server-side wiring called by
13
+ * `@webjsdev/server`; the reader halves stay, because a layout
14
+ * can call `cspNonce()` / `asset()`).
14
15
  *
15
16
  * The framework's own SSR pipeline runs on Node and resolves the
16
17
  * package via the package.json `"default"` condition, which still
@@ -52,7 +53,14 @@ export {
52
53
  // the re-export still also evaluates the module, so the two are belt-and-
53
54
  // suspenders, not a double-enable (ESM evaluates once, enable is idempotent).
54
55
  import './src/router-client.js';
55
- export { enableClientRouter, disableClientRouter, navigate, revalidate } from './src/router-client.js';
56
+ // `loadFrame` is here (and in `./index.js`, keeping the two symmetric) because
57
+ // `@webjsdev/core/client-router`'s `default` condition points at THIS bundle, so
58
+ // in dist mode the subpath resolves here while its published `types`
59
+ // (`src/router-client.d.ts`) declare all five public router functions. Without
60
+ // it, `import { loadFrame } from '@webjsdev/core/client-router'` type-checks,
61
+ // works in dev (where the subpath maps to `src/router-client.js`) and is
62
+ // `undefined` in production.
63
+ export { enableClientRouter, disableClientRouter, navigate, revalidate, loadFrame } from './src/router-client.js';
56
64
  export { WebjsFrame } from './src/webjs-frame.js';
57
65
  export { WebjsStream, renderStream } from './src/webjs-stream.js';
58
66
 
@@ -64,7 +72,7 @@ export { optimistic } from './src/optimistic.js';
64
72
 
65
73
  // SSR action-seed consumer (#472): the generated RPC stub reads a seed on its
66
74
  // first call so async-render hydration does not re-fetch the SSR'd data.
67
- export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js';
75
+ export { takeSeed, scanSeeds, seedStats, SEED_MISS } from './src/action-seed-client.js';
68
76
  // Client tag-cache coordinator for HTTP-verb actions (#488): tag-based
69
77
  // browser-cache eviction after a mutation. Inert server-side.
70
78
  export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js';
package/index.d.ts CHANGED
@@ -81,7 +81,7 @@ export { repeat, isRepeat } from './src/repeat.js';
81
81
  export { Suspense, isSuspense } from './src/suspense.js';
82
82
  export { connectWS } from './src/websocket-client.js';
83
83
  export { richFetch } from './src/rich-fetch.js';
84
- export { enableClientRouter, disableClientRouter, revalidate } from './src/router-client.js';
84
+ export { enableClientRouter, disableClientRouter, revalidate, loadFrame } from './src/router-client.js';
85
85
 
86
86
  // `navigate` is typed against the generated `Route` union (#258) rather than
87
87
  // the JSDoc `string`. Until an app runs `webjs types`, `Route` resolves to
@@ -108,7 +108,7 @@ export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.
108
108
  export { cspNonce, setCspNonceProvider } from './src/csp-nonce.js';
109
109
  export { asset, setAssetUrlProvider } from './src/asset-url.js';
110
110
  export { FORM_ACTION_FIELD, FORM_ACTION_ID_KEY, setFormActionResolver } from './src/form-action.js';
111
- export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js';
111
+ export { takeSeed, scanSeeds, seedStats, SEED_MISS } from './src/action-seed-client.js';
112
112
  export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js';
113
113
  // Client action-abort plumbing (#492): a superseded async render aborts its
114
114
  // in-flight action fetches. Inert server-side.
package/index.js CHANGED
@@ -28,7 +28,7 @@ export {
28
28
  stringify, parse,
29
29
  serialize, deserialize,
30
30
  } from './src/serialize.js';
31
- export { enableClientRouter, disableClientRouter, navigate, revalidate } from './src/router-client.js';
31
+ export { enableClientRouter, disableClientRouter, navigate, revalidate, loadFrame } from './src/router-client.js';
32
32
  export { WebjsFrame } from './src/webjs-frame.js';
33
33
  export { WebjsStream, renderStream } from './src/webjs-stream.js';
34
34
 
@@ -40,7 +40,7 @@ export { optimistic } from './src/optimistic.js';
40
40
 
41
41
  // SSR action-seed consumer (#472): the generated RPC stub reads a seed on its
42
42
  // first call so async-render hydration does not re-fetch. Inert server-side.
43
- export { takeSeed, scanSeeds, SEED_MISS } from './src/action-seed-client.js';
43
+ export { takeSeed, scanSeeds, seedStats, SEED_MISS } from './src/action-seed-client.js';
44
44
  // Client tag-cache coordinator for HTTP-verb actions (#488): tag-based
45
45
  // browser-cache eviction after a mutation. Inert server-side.
46
46
  export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } from './src/action-cache-client.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.47",
3
+ "version": "0.7.49",
4
4
  "type": "module",
5
5
  "description": "The runtime for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Ships the html and css template tags, the WebComponent base class, signals, directives, and the isomorphic renderers.",
6
6
  "types": "./index.d.ts",
@@ -7,11 +7,33 @@ export const SEED_MISS: unique symbol;
7
7
  * Merge any seeds found under `root` (or the whole document) into the global
8
8
  * consume-once store, removing the carriers. Reads the page-level
9
9
  * `#__webjs-seeds` JSON block and per-element `[data-webjs-seed]` carriers.
10
+ *
11
+ * A DETACHED root means a new PAGE is arriving, which evicts whatever the
12
+ * outgoing page left unconsumed. Pass `{ frame: true }` for a `<webjs-frame>`
13
+ * subtree swap, which is not a page navigation and must leave page state alone.
10
14
  */
11
- export function scanSeeds(root?: ParentNode): void;
15
+ export function scanSeeds(
16
+ root?: ParentNode,
17
+ opts?: { frame?: boolean },
18
+ ): void;
12
19
 
13
20
  /**
14
21
  * Look up and CONSUME the seed for an action call, or return `SEED_MISS`.
15
22
  * Keyed `hash/fn/argsKey`; the first call lazily scans the initial document.
16
23
  */
17
24
  export function takeSeed(hash: string, fnName: string, argsKey: string): unknown;
25
+
26
+ /**
27
+ * The cumulative seed counters for this page session (#1309). `ingested` and
28
+ * `replaced` are what `scanSeeds` merged; `hits` and `misses` are what the
29
+ * generated RPC stubs asked for; `pending` is what is still unconsumed.
30
+ */
31
+ export function seedStats(): {
32
+ ingested: number;
33
+ replaced: number;
34
+ hits: number;
35
+ misses: number;
36
+ /** The provable subset of `misses`: a call for an action this page seeded under other arguments. */
37
+ keyMisses: number;
38
+ pending: number;
39
+ };
@@ -3,8 +3,10 @@
3
3
  *
4
4
  * The server (`@webjsdev/server`'s `action-seed.js`) serializes each action
5
5
  * result invoked during SSR into a `<script type="application/json"
6
- * id="__webjs-seeds">` block (and, for streamed regions, per-element
7
- * `data-webjs-seed` attributes). On the client, the generated RPC stub
6
+ * id="__webjs-seeds">` block. A per-element `data-webjs-seed` carrier is also
7
+ * READ here, for a future per-component / streamed-region seeding; no server
8
+ * path emits one today, and a streamed render emits no seeds at all.
9
+ * On the client, the generated RPC stub
8
10
  * (`actions.js`) calls `takeSeed(hash, fn, argsKey)` on its FIRST invocation: a
9
11
  * hit resolves the action synchronously with the SSR value (no RPC, no flicker
10
12
  * on hydration); a miss falls through to the normal RPC.
@@ -31,31 +33,232 @@ export const SEED_MISS = Symbol('webjs.seed.miss');
31
33
  /** One-time eager scan guard (the initial-load document is scanned lazily). */
32
34
  let scannedInitial = false;
33
35
 
36
+ /**
37
+ * Cumulative counters (#1309). ALWAYS on: WebJs is no-build on the server, and
38
+ * the one build that exists (the core browser bundle) cannot express a dev gate,
39
+ * since esbuild substitutes `process.env.NODE_ENV` with "production" and
40
+ * `publicEnvShim` always defines `window.process.env`, so a NODE_ENV guard in
41
+ * this bundle is a constant that reads the wrong way round. Four integer
42
+ * increments on a path that already awaits `stringify(args)` and usually a
43
+ * `fetch`, so nothing is gated here; only the REPORTING is, by a server-emitted
44
+ * marker (see `readDevMarker`).
45
+ */
46
+ const stats = { ingested: 0, replaced: 0, hits: 0, misses: 0, keyMisses: 0 };
47
+
48
+ /**
49
+ * The `hash/fn` of every action this page SEEDED, whatever arguments it used.
50
+ *
51
+ * This is what makes a miss provable. A miss on its own is not evidence of
52
+ * anything: every action call routes through `takeSeed`, including ones that
53
+ * were never SSR-invoked and never could have been seeded (a mutation, a `Task`
54
+ * autorun, a `connectedCallback` read, an `optimistic()` call). But a miss on an
55
+ * action the server DID invoke during this render, under some other argument
56
+ * list, is a genuine key mismatch and nothing else: the action is reachable, it
57
+ * was seeded, and this call still asked for a key the page does not carry.
58
+ *
59
+ * Keyed on the first two segments, which is exact: the file hash and the export
60
+ * name are both `/`-free, and only the serialized argument list can contain one.
61
+ */
62
+ const seedFns = new Set();
63
+
64
+ /**
65
+ * The open report window, or null. Each scan OPENS one and CLOSES the previous,
66
+ * so a window measures exactly the page it was opened for.
67
+ *
68
+ * The window has to be the hydration window (scan to idle), because a miss AFTER
69
+ * it is correct behaviour: the seed is consume-once, so a deliberate refetch or
70
+ * an argument change is SUPPOSED to miss. Measure any wider and correct misses
71
+ * are reported as defects, which is what trains a developer to filter the
72
+ * channel out.
73
+ *
74
+ * "Wider" has two directions and both were live at some point in this PR. Late:
75
+ * snapshotting at report time rather than schedule time banks post-hydration
76
+ * misses and charges them to the next page. Early: letting a report stay open
77
+ * across a soft navigation folds the NEXT page's seeds and calls into the
78
+ * previous page's numbers, so a healthy page gets a defect line and the page
79
+ * that actually had one gets no report at all. Closing on the next scan bounds
80
+ * it from both ends, and the marker rides the window for the same reason the
81
+ * counters do.
82
+ *
83
+ * `epoch` lets a scheduled callback tell whether its window is still the open
84
+ * one; a window closed early (by the next scan, or by `__resetSeeds`) leaves a
85
+ * callback behind that must be a no-op rather than report a window it does not
86
+ * own.
87
+ * @type {{ epoch: number, hits: number, misses: number, keyMisses: number, merged: number, marker: string } | null}
88
+ */
89
+ let openWindow = null;
90
+ /** Monotonic window id, so a stale callback can recognise itself. */
91
+ let windowEpoch = 0;
92
+
34
93
  /**
35
94
  * Merge any seeds found under `root` into the global store, then remove the
36
- * carriers so a re-scan (a streamed boundary, a soft navigation) never
37
- * re-ingests stale data. Reads both the page-level `#__webjs-seeds` JSON block
38
- * and per-element `[data-webjs-seed]` carriers. Idempotent and fail-open: a
39
- * malformed payload is skipped, never thrown.
95
+ * carriers so a re-scan never re-ingests stale data. Reads both the page-level
96
+ * `#__webjs-seeds` JSON block and per-element `[data-webjs-seed]` carriers.
97
+ * Idempotent and fail-open: a malformed payload is skipped, never thrown.
98
+ *
99
+ * A DETACHED root means a new PAGE is arriving (the router's `applySwap` passes
100
+ * one), which is what ends the outgoing page's report window and discards its
101
+ * leftovers. A `<webjs-frame>` swap goes through the same `applySwap` but is NOT
102
+ * a page navigation, so the router flags it and this leaves every bit of page
103
+ * state alone; see `opts.frame`.
40
104
  * @param {ParentNode & { querySelectorAll?: Function }} [root]
105
+ * @param {{ frame?: boolean }} [opts] `frame: true` marks a `<webjs-frame>`
106
+ * subtree swap rather than a page navigation.
41
107
  */
42
- export function scanSeeds(root) {
43
- const scope = root || (typeof document !== 'undefined' ? document : null);
108
+ export function scanSeeds(root, opts) {
109
+ const live = typeof document !== 'undefined' ? document : null;
110
+ const scope = root || live;
44
111
  if (!scope || typeof scope.querySelectorAll !== 'function') return;
45
- // Page-level JSON block(s).
112
+
113
+ // A FRAME swap is not a page navigation: the page around the frame is still
114
+ // the page on screen. So touch no page state at all, no window closed or
115
+ // opened and no lazy-scan suppression, and DISCARD whatever the parse carries.
116
+ //
117
+ // The router has to say which it is, because the parse cannot be recognised
118
+ // from its own shape. `ssr.js` slices out a bare frame subtree ONLY when the
119
+ // id was found and the render did not stream; in every other case it falls
120
+ // through and serves the WHOLE PAGE, seed block and all, so a frame response
121
+ // is not reliably distinguishable from a page that seeded nothing. Guessing
122
+ // failed in both directions in turn: treating it as a page killed the report
123
+ // for the whole page the frame sits on, and attributing the window to the live
124
+ // page made it report a CAUSE that is provably false, since none of the causes
125
+ // describes a frame response. (The response's own `X-Webjs-Seed` is no help
126
+ // either: the isolable case returns before the header is set, and the three
127
+ // fallthrough shapes below carry the SURROUNDING page's header, which says
128
+ // nothing about the frame.)
129
+ //
130
+ // Discarding is right for every shape the fallthrough produces, and they are
131
+ // not all the same shape:
132
+ // - ISOLABLE (id found, buffered): the subtree was sliced before the block
133
+ // was appended, so there is nothing to discard.
134
+ // - STREAMED: the whole page is served and the frame IS in it, so the swap
135
+ // SUCCEEDS. In dev the page carries a marker-only block, which holds no
136
+ // seeds anyway.
137
+ // - #241 HTML CACHE HIT: the cached page is returned before the slice, so the
138
+ // parse carries a real, populated block, and the swap succeeds. Those seeds
139
+ // describe the whole page, not the one region being swapped in, and the
140
+ // page on screen already has its own.
141
+ // - ID MISSING: the whole page is served, the router dispatches
142
+ // `webjs:frame-missing`, and the response is thrown away entirely.
143
+ //
144
+ // Ingesting in the last three would put another response's seeds in the store,
145
+ // where last-write-wins hands them to a component on the page still on screen,
146
+ // which is the exact "a hit disagrees with the paint" class this feature
147
+ // promises cannot happen.
148
+ if (opts && opts.frame === true) {
149
+ drainCarriers(scope, true);
150
+ return;
151
+ }
152
+
153
+ // Close the previous page's window BEFORE ingesting anything: the moment this
154
+ // page's content arrives is exactly where the previous page's numbers stop. Do
155
+ // it after the ingest loops instead and this scan's seeds are already counted
156
+ // into the window being closed, which is the whole defect.
157
+ closeReportWindow();
158
+
159
+ // Is this root the live document, or part of it? A live root (the lazy initial
160
+ // scan) or a live subtree is not an incoming page, and must not trigger the
161
+ // discard below: it would strip the very subtree being scanned.
162
+ const scopeIsLive = scope === live;
163
+ const scopeInLive = !!live && !scopeIsLive && typeof live.contains === 'function' && live.contains(scope);
164
+ const incomingPage = !!live && !scopeIsLive && !scopeInLive;
165
+
166
+ // The live document can still be holding the OUTGOING page's block, because
167
+ // the initial scan is lazy (it runs on the first `takeSeed`) and a page whose
168
+ // async components all elided never triggers it, while the block sits after
169
+ // the body content outside every boundary range, so no swap removes it either.
170
+ //
171
+ // Strip it WITHOUT ingesting. Those values belong to a page that is being
172
+ // replaced, so nothing on the incoming page should ever receive one: leaving
173
+ // them for the lazy scan to ingest LATER is what let a departed page's value
174
+ // win a key under last-write-wins and contradict the paint. Ingesting them
175
+ // HERE would order them correctly but keep them forever, because the very
176
+ // reason the block went unconsumed is that its components elided, so nothing
177
+ // will ever call `takeSeed` for those keys and nothing but a hit deletes one.
178
+ // That would grow the store by a whole page payload per navigation, in
179
+ // production, and inflate the "still unconsumed" figure in the dev line with
180
+ // keys from pages the developer has already left. The cost of discarding is
181
+ // that an in-flight `async render()` from the outgoing page misses and
182
+ // refetches, which is a round-trip rather than wrong data.
183
+ if (incomingPage) {
184
+ drainCarriers(live, true);
185
+ // And evict what the outgoing page ALREADY ingested. Stripping the DOM
186
+ // carrier is not enough: a page carrying an elided component alongside a
187
+ // shipping one has its WHOLE block ingested by the lazy scan the shipping
188
+ // one triggers, so the elided component's keys are in the store with no
189
+ // carrier left to strip and nothing that will ever consume them. Navigate to
190
+ // a page that emits no seed for such a key (a streamed page emits no block
191
+ // at all) and a component calling the same action with the same arguments
192
+ // gets a HIT carrying the DEPARTED page's value, painted over the fresh SSR
193
+ // HTML. That is the same "a hit disagrees with the paint" class the drain
194
+ // above closes, reached through the store instead of through the DOM, so it
195
+ // has to close here too or the guarantee is not one.
196
+ //
197
+ // Everything still held at this moment belongs to the page being replaced:
198
+ // a hit deletes its key, so anything left is unconsumed. Dropping it costs
199
+ // an in-flight `async render()` from the outgoing page a round-trip, never
200
+ // wrong data.
201
+ seeds.clear();
202
+ seedFns.clear();
203
+ }
204
+ // The lazy scan must not run again once the live document has been handled: a
205
+ // second pass finds nothing to ingest but would close the window this scan is
206
+ // about to open. Only when it HAS been handled, though. A live SUBTREE scan
207
+ // leaves the rest of the document unscanned, so the lazy pass still owes it.
208
+ if (scopeIsLive || incomingPage) scannedInitial = true;
209
+
210
+ // MERGED is `ingested + replaced`, not `ingested`: a key already in the store
211
+ // counts as a replacement, so a scan whose seeds all replace unconsumed ones
212
+ // would otherwise measure as zero and claim the page carried no seeds while
213
+ // naming one. A page navigation can no longer produce that shape (it evicts
214
+ // first), but a re-scan of the same document still can, and the count has to
215
+ // mean "merged by this scan" either way.
216
+ const mergedBefore = stats.ingested + stats.replaced;
217
+ startReportWindow(drainCarriers(scope), mergedBefore);
218
+ }
219
+
220
+ /**
221
+ * Strip every seed carrier under `scope`, returning the dev marker it carried
222
+ * (the page-level block's, the only carrier that has one) or null. Ingests the
223
+ * values unless `discard` is set, which is how an outgoing page's leftovers are
224
+ * removed without ever entering the store.
225
+ * @param {ParentNode & { querySelectorAll: Function }} scope
226
+ * @param {boolean} [discard] strip the carriers but drop their values
227
+ * @returns {string | null}
228
+ */
229
+ function drainCarriers(scope, discard) {
230
+ let marker = null;
231
+ // Page-level JSON block(s). The dev marker rides this carrier only, and it is
232
+ // THIS scan's marker that decides whether there is a report to make and what
233
+ // cause it names.
46
234
  for (const el of scope.querySelectorAll('script[type="application/json"]#__webjs-seeds, script[type="application/json"][data-webjs-seeds]')) {
47
- ingest(el.textContent, el);
235
+ const m = readDevMarker(el.getAttribute?.('data-webjs-dev'));
236
+ if (m !== null) marker = m;
237
+ ingest(discard ? null : el.textContent, el);
48
238
  }
49
239
  // Per-element carriers (streamed boundaries / future per-component seeding).
50
240
  for (const el of scope.querySelectorAll('[data-webjs-seed]')) {
51
- ingest(el.getAttribute('data-webjs-seed'), el, () => el.removeAttribute('data-webjs-seed'));
241
+ ingest(discard ? null : el.getAttribute('data-webjs-seed'), el, () => el.removeAttribute('data-webjs-seed'));
52
242
  }
243
+ return marker;
53
244
  }
54
245
 
55
246
  /**
56
- * Parse one serialized seed payload and merge it (first write wins, so an
57
- * already-consumed or earlier seed is never clobbered by a later duplicate),
58
- * then run `cleanup` (or remove the element).
247
+ * Parse one serialized seed payload and merge it, then run `cleanup` (or remove
248
+ * the element).
249
+ *
250
+ * LAST write wins. A hit DELETES its key (`takeSeed`), so a duplicate can only
251
+ * mean "a later carrier emitted a seed for a key an earlier one left
252
+ * unconsumed", and the later carrier is the one whose paint is on screen.
253
+ *
254
+ * Note what this rule is NOT load-bearing for any more. It was introduced for
255
+ * the cross-PAGE case, where a departed render's value could win a shared key;
256
+ * that case is now closed at the source, because an incoming page evicts the
257
+ * outgoing page's carriers AND its already-ingested seeds before ingesting its
258
+ * own, so no cross-render duplicate can reach here at all. What remains is the
259
+ * within-page case: two carriers in one scan, or a re-scan of the same document,
260
+ * where the later one is still the right answer and first-write-wins would still
261
+ * be the wrong one.
59
262
  * @param {string | null} raw
60
263
  * @param {Element} el
61
264
  * @param {() => void} [cleanup]
@@ -65,7 +268,13 @@ function ingest(raw, el, cleanup) {
65
268
  try {
66
269
  const obj = parse(raw);
67
270
  if (obj && typeof obj === 'object') {
68
- for (const k in obj) if (!seeds.has(k)) seeds.set(k, obj[k]);
271
+ for (const k in obj) {
272
+ if (seeds.has(k)) stats.replaced++;
273
+ else stats.ingested++;
274
+ seeds.set(k, obj[k]);
275
+ const cut = k.indexOf('/', k.indexOf('/') + 1);
276
+ if (cut > 0) seedFns.add(k.slice(0, cut));
277
+ }
69
278
  }
70
279
  } catch {
71
280
  // Malformed payload: ignore, the stub re-fetches.
@@ -75,11 +284,116 @@ function ingest(raw, el, cleanup) {
75
284
  else el.remove?.();
76
285
  }
77
286
 
287
+ /**
288
+ * Read the server's dev marker off one carrier, the ONLY dev signal this bundle
289
+ * can trust (a `process.env.NODE_ENV` comparison here is a compile-time
290
+ * constant, see `stats`). Absent in production, so the reporting below never
291
+ * runs there. Pure: the value belongs to the scan that read it, never to the
292
+ * module.
293
+ * @param {string | null | undefined} v the `data-webjs-dev` attribute value
294
+ * @returns {string | null} the marker, or null when this carrier had none
295
+ */
296
+ function readDevMarker(v) {
297
+ if (typeof v !== 'string') return null;
298
+ return v || 'ok';
299
+ }
300
+
301
+ /**
302
+ * Open the window for this scan. Called at the END of a scan, once the merged
303
+ * count for it is known; the window it supersedes was already closed at the TOP
304
+ * of the scan, so a markerless scan still ends the previous page's window.
305
+ * @param {string | null} scanMarker THIS scan's `data-webjs-dev` marker, or null
306
+ * @param {number} mergedBefore merged-seed count as it stood before this scan
307
+ */
308
+ function startReportWindow(scanMarker, mergedBefore) {
309
+ // The previous window was already closed at the top of the scan, before any
310
+ // ingest. No marker at all means there is nothing to report on: production
311
+ // never emits one, and a back/forward restore of a page that WAS scanned
312
+ // carries no block, because it was stripped before the snapshot was
313
+ // serialized. A restore of a page that was NEVER scanned does carry one, and
314
+ // that block is that page's own, so ingesting it and reporting on it are both
315
+ // right.
316
+ if (scanMarker === null) return;
317
+ const epoch = ++windowEpoch;
318
+ openWindow = { epoch, hits: stats.hits, misses: stats.misses, keyMisses: stats.keyMisses, merged: mergedBefore, marker: scanMarker };
319
+ const run = () => {
320
+ if (!openWindow || openWindow.epoch !== epoch) return; // already closed; not ours
321
+ closeReportWindow();
322
+ };
323
+ try {
324
+ if (typeof requestIdleCallback === 'function') requestIdleCallback(run, { timeout: 1000 });
325
+ else setTimeout(run, 250);
326
+ } catch {
327
+ openWindow = null;
328
+ }
329
+ }
330
+
331
+ /** Close the open window and report on it. A no-op when none is open. */
332
+ function closeReportWindow() {
333
+ const w = openWindow;
334
+ openWindow = null;
335
+ if (!w) return;
336
+ try { reportSeeds(w); } catch { /* diagnostics never break a page */ }
337
+ }
338
+
339
+ /**
340
+ * One dev console line per page view, and ONLY on a defect. A healthy line every
341
+ * navigation trains a developer to filter the channel out, and the healthy
342
+ * numbers are already on the server's access-log line for every request.
343
+ */
344
+ function reportSeeds(w) {
345
+ const hits = stats.hits - w.hits;
346
+ const misses = stats.misses - w.misses;
347
+ const merged = (stats.ingested + stats.replaced) - w.merged;
348
+ const keyMisses = stats.keyMisses - w.keyMisses;
349
+ if (misses === 0) return;
350
+ // Report ONLY a cause this side can prove. A miss is not evidence on its own:
351
+ // EVERY action call routes through `takeSeed`, including ones that were never
352
+ // SSR-invoked and never could have been seeded (a mutation, a `Task` autorun,
353
+ // a `connectedCallback` read, an `optimistic()` call). Warning on those tells
354
+ // a developer whose code is correct to go audit it, on every page view, with
355
+ // no way to silence it, which is the false alarm that trains people to filter
356
+ // the channel out.
357
+ //
358
+ // Two shapes are provable. A `streamed` or `drop` marker is the SERVER saying
359
+ // no seed could ride the page, so every miss on it is explained. And a miss on
360
+ // an action this page DID seed under other arguments (`seedFns`) is a genuine
361
+ // key mismatch, because the action is demonstrably reachable and seeded. A
362
+ // miss on an action that was never seeded at all is left alone: that is the
363
+ // ambiguous case, and the server's `X-Webjs-Seed` states it unambiguously.
364
+ const cause = w.marker === 'streamed'
365
+ ? '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.'
366
+ : w.marker === 'drop'
367
+ ? 'The page\'s seeds could not be serialized, so the whole block was dropped. Something an action returned is not serializer-safe; the server response reports collected above emitted.'
368
+ : keyMisses === 0
369
+ ? null
370
+ : 'The key is the action file hash plus the function name plus the serialized arguments, so an argument the SSR render never used misses (a deliberate refetch after hydration misses too, and is expected). A miss on an action this page never seeded is NOT counted here, because a mutation or a client-only read could never have been seeded.';
371
+ if (cause === null) return;
372
+ // The number and the predicate have to agree. For a server-asserted cause the
373
+ // marker explains EVERY miss, so the total is the right figure and "went to
374
+ // the network" is the right claim. For a key mismatch only the provable subset
375
+ // is explained, and calling that subset the round-trip count understates the
376
+ // traffic: a page mixing one key mismatch with one mutation had 2 calls go out
377
+ // while the line said 1. So that case reports its own count against its own
378
+ // claim, and never speaks for the misses it cannot account for.
379
+ const serverAsserted = w.marker === 'streamed' || w.marker === 'drop';
380
+ const headline = serverAsserted
381
+ ? `${misses} of ${hits + misses} action call(s) in the hydration window went to the network`
382
+ : `${keyMisses} action call(s) in the hydration window asked for a key this page seeded under DIFFERENT arguments`;
383
+ console.warn(
384
+ `[webjs] SSR action seeding: ${headline} `
385
+ + `(${merged} seed(s) on this page, ${seeds.size} still unconsumed). ${cause} `
386
+ + 'See https://webjs.dev/docs/data-fetching for the seeding reference.',
387
+ );
388
+ }
389
+
78
390
  /**
79
391
  * Look up and CONSUME the seed for an action call. Returns the seeded value
80
392
  * (removing it) on a hit, or `SEED_MISS` when there is none. The first call
81
393
  * lazily scans the initial document, so the boot path needs no wiring; the
82
- * router calls `scanSeeds(subtree)` for content that arrives later.
394
+ * router calls `scanSeeds(doc)` with the DETACHED parse of content that arrives
395
+ * later, which is also what tells this module a new page is replacing the one on
396
+ * screen.
83
397
  * @param {string} hash the action file's hash (the RPC endpoint hash)
84
398
  * @param {string} fnName the exported action name
85
399
  * @param {string} argsKey `stringify(args)`, computed by the stub with the same
@@ -95,13 +409,41 @@ export function takeSeed(hash, fnName, argsKey) {
95
409
  if (seeds.has(key)) {
96
410
  const v = seeds.get(key);
97
411
  seeds.delete(key);
412
+ stats.hits++;
98
413
  return v;
99
414
  }
415
+ stats.misses++;
416
+ // A miss on an action this page DID seed under other arguments is the only
417
+ // miss the client can call a defect; see `seedFns`.
418
+ if (seedFns.has(`${hash}/${fnName}`)) stats.keyMisses++;
100
419
  return SEED_MISS;
101
420
  }
102
421
 
103
- /** Test seam: drop all seeds and reset the lazy-scan guard. */
422
+ /**
423
+ * The cumulative seed counters for this page session (#1309). `ingested` and
424
+ * `replaced` are what `scanSeeds` merged; `hits` and `misses` are what the
425
+ * generated RPC stubs asked for; `pending` is what is still in the store
426
+ * unconsumed (a non-zero `pending` at rest usually means the seeding component
427
+ * elided, so nothing on the client was ever going to call it).
428
+ * `keyMisses` is the provable subset of `misses`: a call for an action this page
429
+ * DID seed under other arguments, which is the only miss the client can call a
430
+ * defect (see `seedFns`).
431
+ * @returns {{ ingested: number, replaced: number, hits: number, misses: number, keyMisses: number, pending: number }}
432
+ */
433
+ export function seedStats() {
434
+ return { ...stats, pending: seeds.size };
435
+ }
436
+
437
+ /** Test seam: drop all seeds and reset the lazy-scan guard + the counters. */
104
438
  export function __resetSeeds() {
105
439
  seeds.clear();
106
440
  scannedInitial = false;
441
+ stats.ingested = 0;
442
+ stats.replaced = 0;
443
+ stats.hits = 0;
444
+ stats.misses = 0;
445
+ stats.keyMisses = 0;
446
+ seedFns.clear();
447
+ openWindow = null; // drop it silently; a reset is not a page view
448
+ windowEpoch++; // and burn any in-flight callback rather than let it report
107
449
  }
@@ -160,9 +160,13 @@ interface WebComponentStatics {
160
160
  /**
161
161
  * Force this component to ship (hydrate) even when the elision analyser would
162
162
  * drop it as display-only. The escape hatch for interactivity the analyser
163
- * cannot see statically: a dynamically-computed tag string, or a `:defined`
164
- * rule in an external stylesheet outside the module graph. Leave unset for
165
- * the automatic verdict.
163
+ * cannot see statically: an OBSERVER that computes the tag it waits for, a
164
+ * `:defined` rule in an external stylesheet outside the module graph, or a
165
+ * consumer reaching the element through a string selector. It
166
+ * cannot rescue a component whose OWN registration tag is computed, since
167
+ * the scanner requires a literal tag (invariant 3) and never sees that
168
+ * component at all. Leave unset for the automatic verdict; inspect it with
169
+ * `webjs elision`.
166
170
  */
167
171
  interactive?: boolean;
168
172
  register(tag: string): void;