@webjsdev/core 0.7.50 → 0.7.53

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.
Files changed (64) hide show
  1. package/dist/webjs-core-browser.js +5 -5
  2. package/dist/webjs-core-browser.js.map +3 -3
  3. package/dist/webjs-core-testing.js +20 -20
  4. package/dist/webjs-core-testing.js.map +3 -3
  5. package/dist/webjs-core.js +7 -7
  6. package/dist/webjs-core.js.map +3 -3
  7. package/index-browser.js +1 -1
  8. package/index.d.ts +3 -3
  9. package/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/component/lifecycle.js +1481 -0
  12. package/src/component/reactive.js +171 -0
  13. package/src/component/server-element.js +248 -0
  14. package/src/component.js +2 -1896
  15. package/src/css.d.ts +9 -0
  16. package/src/escape.d.ts +2 -0
  17. package/src/html.d.ts +9 -0
  18. package/src/render-client/parts.js +2039 -0
  19. package/src/render-client/reconciler.js +388 -0
  20. package/src/render-client/template-compiler.js +525 -0
  21. package/src/render-client.js +1 -2938
  22. package/src/render-server/attrs.js +160 -0
  23. package/src/render-server/dsd.js +445 -0
  24. package/src/render-server/html-scan.js +402 -0
  25. package/src/render-server/slots.js +213 -0
  26. package/src/render-server/stream.js +163 -0
  27. package/src/render-server/template-renderer.js +938 -0
  28. package/src/render-server/text.js +163 -0
  29. package/src/render-server.js +6 -2405
  30. package/src/repeat.d.ts +15 -0
  31. package/src/rich-fetch.d.ts +4 -0
  32. package/src/router-client/anchors.js +44 -0
  33. package/src/router-client/boundaries.js +221 -0
  34. package/src/router-client/constants.js +206 -0
  35. package/src/router-client/diagnostics.js +377 -0
  36. package/src/router-client/dom-differ.js +709 -0
  37. package/src/router-client/dom-parse.js +150 -0
  38. package/src/router-client/events.js +182 -0
  39. package/src/router-client/fetch-apply.js +432 -0
  40. package/src/router-client/form-encoder.js +135 -0
  41. package/src/router-client/frames.js +329 -0
  42. package/src/router-client/head-merge.js +344 -0
  43. package/src/router-client/nav-error.js +105 -0
  44. package/src/router-client/navigator.js +952 -0
  45. package/src/router-client/prefetch.js +831 -0
  46. package/src/router-client/scroll.js +308 -0
  47. package/src/router-client/snapshot-cache.js +113 -0
  48. package/src/router-client/state.js +155 -0
  49. package/src/router-client/stream.js +206 -0
  50. package/src/router-client/swap.js +454 -0
  51. package/src/router-client/upgrade.js +261 -0
  52. package/src/router-client/view-transition.js +223 -0
  53. package/src/router-client.d.ts +11 -2
  54. package/src/router-client.js +78 -5345
  55. package/src/slot/assignment.js +304 -0
  56. package/src/slot/interception.js +558 -0
  57. package/src/slot/polyfills.js +365 -0
  58. package/src/slot/project.js +432 -0
  59. package/src/slot/sensors.js +303 -0
  60. package/src/slot/state.js +245 -0
  61. package/src/slot/symbols.js +85 -0
  62. package/src/slot.js +7 -2199
  63. package/src/suspense.d.ts +9 -0
  64. package/src/websocket-client.d.ts +22 -0
@@ -0,0 +1,831 @@
1
+ /**
2
+ * Client router: prefetch.
3
+ *
4
+ * Moved verbatim out of the pre-split `router-client.js`; that barrel holds
5
+ * the router's full contract and the public entry points.
6
+ *
7
+ * @module
8
+ */
9
+ import { closestAnchor } from './anchors.js';
10
+ import { buildHaveHeader } from './boundaries.js';
11
+ import { NON_HTML_EXTENSIONS } from './constants.js';
12
+ import { liveFrameElement, resolveTargetFrameId } from './frames.js';
13
+ import { enabled } from './state.js';
14
+
15
+ import { cacheKey, snapshotCache } from './snapshot-cache.js';
16
+
17
+ /** Max speculative responses held at once (LRU). */
18
+ const PREFETCH_CAP = 8;
19
+
20
+ /** Speculative entries expire after this long (ms): avoid serving stale. */
21
+ const PREFETCH_TTL = 30_000;
22
+
23
+ /** Max concurrent in-flight prefetch requests. */
24
+ const PREFETCH_CONCURRENCY = 3;
25
+
26
+ /** Max prefetches waiting for a free slot (bounds a huge link list). */
27
+ const PREFETCH_QUEUE_CAP = 24;
28
+
29
+ /** Hover dwell before a prefetch fires (ms): filter drive-by pointer moves. Matches Remix's intent timeout. */
30
+ const PREFETCH_HOVER_DELAY = 100;
31
+
32
+ /**
33
+ * Viewport dwell before a prefetch fires (ms): a link must SETTLE on-screen,
34
+ * not merely flash past during a scroll. A fast scroll-through clears the
35
+ * timer on exit, so flicked-past links never fetch. Astro uses 300ms for the
36
+ * same purpose; we sit a touch lower so a deliberate stop still feels instant.
37
+ */
38
+ const PREFETCH_VIEWPORT_DELAY = 250;
39
+
40
+ /** @typedef {{ html: string, build: string | null, src: string | null, finalUrl: string, frameId: string | null, at: number }} PrefetchEntry */
41
+ /** @type {Map<string, PrefetchEntry>} */
42
+ export const prefetchCache = new Map();
43
+
44
+ /**
45
+ * Keys whose last speculative answer was REFUSED, with the time it was refused
46
+ * (#1407). Only a framed request can be refused: the server marks its sliced
47
+ * subtree, so an unmarked answer is one of the two full-document fall-throughs
48
+ * (a streamed render, or an id absent from the output) and is not the shape the
49
+ * entry claims.
50
+ *
51
+ * This exists so a refusal is not the same as forgetting. With no record, the
52
+ * TTL dedupe would have nothing to match and `prefetchInflight` is released the
53
+ * moment the fetch settles, so every later hover or dwell on that link would
54
+ * re-issue the same useless request for as long as the page lives. A route with
55
+ * a `loading.{js,ts}` or a Suspense boundary streams, so it answers EVERY framed
56
+ * request unmarked, which would make that the normal case there and strictly
57
+ * worse than before this feature (an unframed prefetch was at least cached and
58
+ * deduped for the TTL).
59
+ *
60
+ * It is deliberately NOT an entry in `prefetchCache`. That cache holds fragments
61
+ * a click can consume, it is capped at `PREFETCH_CAP`, and an entry leaves it as
62
+ * soon as a click takes it. A memo holds no fragment and can never serve a
63
+ * click, so filing one there would let a streaming route's framed links occupy
64
+ * slots that genuinely warm fragments are competing for, and hold them longer
65
+ * than any real entry, since nothing consumes a memo. Its own cap bounds it
66
+ * instead.
67
+ *
68
+ * @type {Map<string, number>}
69
+ */
70
+ const prefetchRefused = new Map();
71
+
72
+ /** Max refusal memos held at once (LRU), independent of the fragment cache. */
73
+ const PREFETCH_REFUSED_CAP = 16;
74
+
75
+ /**
76
+ * Whether this key's last answer was refused recently enough that re-asking
77
+ * would just be refused again. Prunes on read, so an expired memo never
78
+ * suppresses a legitimate retry.
79
+ *
80
+ * @param {string} key
81
+ */
82
+ function prefetchRefusedRecently(key) {
83
+ const at = prefetchRefused.get(key);
84
+ if (at == null) return false;
85
+ if ((nowMs() - at) >= PREFETCH_TTL) { prefetchRefused.delete(key); return false; }
86
+ return true;
87
+ }
88
+
89
+ /**
90
+ * Forget every refusal (#1407). Called from the two places where the SOURCE may
91
+ * have changed under us: wherever a DEPLOY is detected, and `refreshPage`, the
92
+ * dev live-reload path, where an edit can add or remove a `Suspense` boundary
93
+ * and so start or stop the route streaming. Only an edit to a file the browser
94
+ * does NOT download arrives there; anything that SHIPS takes a full reload
95
+ * instead. A `loading.{js,ts}` reloads either way, though for two different
96
+ * reasons: editing or deleting one reloads because it can never be elided and
97
+ * so always ships, while ADDING one reloads because a brand-new file is in no
98
+ * set the previous build produced.
99
+ *
100
+ * NEITHER form of `revalidate` clears them, which is a cost/benefit call rather
101
+ * than an impossibility. It is the post-MUTATION api an app calls after an RPC
102
+ * write, so clearing there would drop every memo on every mutation and reopen
103
+ * the request-per-hover loop this exists to close. A mutation CAN change a given
104
+ * render's streamed shape, since a page may render `Suspense` conditionally on
105
+ * fetched data, but the cost of a memo that outlives that is bounded to one
106
+ * skipped warm-up for that key until the 30s TTL runs out, which is the cheaper
107
+ * side of the trade.
108
+ */
109
+ export function clearPrefetchRefused() {
110
+ prefetchRefused.clear();
111
+ }
112
+
113
+ /** @param {string} key */
114
+ function notePrefetchRefused(key) {
115
+ if (prefetchRefused.has(key)) prefetchRefused.delete(key);
116
+ prefetchRefused.set(key, nowMs());
117
+ while (prefetchRefused.size > PREFETCH_REFUSED_CAP) {
118
+ prefetchRefused.delete(prefetchRefused.keys().next().value);
119
+ }
120
+ }
121
+
122
+ /** Keys with a fetch currently in flight (dedupe + concurrency gate). */
123
+ const prefetchInflight = new Set();
124
+
125
+ /**
126
+ * Keys whose IN-FLIGHT fetch has been superseded by an eviction (#1407).
127
+ * `prefetchEvict` can only delete what is already stored, and a speculative
128
+ * fetch stores when its body finishes reading, so an eviction landing inside
129
+ * that window would be undone the moment the response arrives. Reachable
130
+ * without contrivance: hover a framed tab (the dwell arms a fetch), then mutate
131
+ * that frame's `src` to the same url inside the request's window. A key parked
132
+ * here makes the pending store a no-op instead. Bounded by the in-flight set,
133
+ * since every entry is removed when its fetch settles.
134
+ *
135
+ * @type {Set<string>}
136
+ */
137
+ const prefetchSuperseded = new Set();
138
+
139
+ /** hrefs waiting for a free concurrency slot (FIFO), and their keys. */
140
+ const prefetchQueue = [];
141
+
142
+ const prefetchQueued = new Set();
143
+
144
+ /** Pending hover-dwell timer, cleared on pointerout / blur. */
145
+ let prefetchHoverTimer = null;
146
+
147
+ /** Last anchor a hover timer was armed for (so pointerout can match). */
148
+ let prefetchHoverAnchor = null;
149
+
150
+ /** IntersectionObserver for data-prefetch="viewport" anchors, or null. */
151
+ export let prefetchViewObserver = null;
152
+
153
+ /** Per-anchor viewport-dwell timers, so a scroll-out can cancel before firing. */
154
+ let prefetchViewTimers = new WeakMap();
155
+
156
+ /** Live viewport-dwell timer ids, for bulk teardown on disable. */
157
+ const prefetchViewPending = new Set();
158
+
159
+ /**
160
+ * True when the user or platform has asked us to conserve data, OR the
161
+ * connection is too slow to spend bytes speculatively. The Save-Data client
162
+ * hint, the prefers-reduced-data media query, and a 2g `effectiveType` all
163
+ * disable speculative fetching, the same gate Astro / Nuxt apply. Guarded for
164
+ * non-browser / partial DOM.
165
+ *
166
+ * @returns {boolean}
167
+ */
168
+ export function prefetchSaysSaveData() {
169
+ try {
170
+ const c = typeof navigator !== 'undefined' ? /** @type any */ (navigator).connection : null;
171
+ if (c) {
172
+ if (c.saveData === true) return true;
173
+ // effectiveType is 'slow-2g' | '2g' | '3g' | '4g'; skip the 2g tiers.
174
+ if (typeof c.effectiveType === 'string' && /2g$/.test(c.effectiveType)) return true;
175
+ }
176
+ if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-data: reduce)').matches) {
177
+ return true;
178
+ }
179
+ } catch { /* ignore */ }
180
+ return false;
181
+ }
182
+
183
+ /**
184
+ * Whether the device drives a hover-capable fine pointer (a mouse or
185
+ * trackpad), as opposed to touch. This picks the ADAPTIVE prefetch default:
186
+ * `intent` (hover / focus) on a pointer device, `viewport` on touch, since a
187
+ * touch device has no hover and `touchstart` fires too close to the tap to
188
+ * front-run it. Detected with `matchMedia('(hover: hover) and (pointer: fine)')`
189
+ * rather than a user-agent sniff. When `matchMedia` is unavailable we assume a
190
+ * pointer (the historical default), so a non-browser / partial-DOM environment
191
+ * keeps the `intent` behaviour and never silently switches to viewport.
192
+ *
193
+ * @returns {boolean}
194
+ */
195
+ export function prefetchHasHoverPointer() {
196
+ try {
197
+ if (typeof matchMedia === 'function') {
198
+ return matchMedia('(hover: hover) and (pointer: fine)').matches;
199
+ }
200
+ } catch { /* ignore */ }
201
+ return true;
202
+ }
203
+
204
+ /**
205
+ * Lowercased whitespace-separated rel tokens of an anchor.
206
+ * @param {Element} anchor
207
+ * @returns {string[]}
208
+ */
209
+ function relTokens(anchor) {
210
+ const rel = anchor.getAttribute('rel');
211
+ return rel ? rel.toLowerCase().split(/\s+/).filter(Boolean) : [];
212
+ }
213
+
214
+ /**
215
+ * Decide whether an anchor is a same-origin in-app target the router can
216
+ * navigate, returning its absolute href or null. Used by the prefetch
217
+ * listeners, and it MIRRORS the filtering `onClick` performs inline rather
218
+ * than being shared with it, so the two must be changed together.
219
+ *
220
+ * @param {Element | null} anchor
221
+ * @returns {string | null}
222
+ */
223
+ export function eligibleAnchorHref(anchor) {
224
+ if (!anchor || !(anchor instanceof HTMLAnchorElement)) return null;
225
+ if (anchor.hasAttribute('download')) return null;
226
+ if (anchor.hasAttribute('data-no-router')) return null;
227
+ if (anchor.target && anchor.target !== '_self') return null;
228
+ const href = anchor.href;
229
+ if (!href) return null;
230
+ let url;
231
+ try { url = new URL(href); } catch { return null; }
232
+ if (url.origin !== location.origin) return null;
233
+ // A pure same-page fragment jump is not a navigation we fetch. Tested by
234
+ // `href` rather than by `hash` for the reason spelled out at the matching
235
+ // line in `events.js` (a null and an empty fragment both read as `''`). These
236
+ // two lines must stay in lockstep: `onClick` keeps its own copy rather than
237
+ // calling this, so drift here means a link the click path ignores becomes
238
+ // prefetch-eligible. #1106 already refuses the current url in every
239
+ // dimension, so this is drift prevention, not a change in what is fetched.
240
+ if (url.pathname === location.pathname && url.search === location.search && url.href.includes('#')) return null;
241
+ if (NON_HTML_EXTENSIONS.test(url.pathname)) return null;
242
+ return href;
243
+ }
244
+
245
+ /**
246
+ * Whether prefetching this anchor is suppressed by author intent. The
247
+ * `external` rel marks a link leaving the app, `no-prefetch` and
248
+ * `data-no-prefetch` are explicit opt-outs, and `data-no-router` already
249
+ * disables routing entirely (so it is caught upstream too).
250
+ *
251
+ * @param {Element} anchor
252
+ * @returns {boolean}
253
+ */
254
+ export function prefetchSuppressed(anchor) {
255
+ if (anchor.hasAttribute('data-no-prefetch')) return true;
256
+ const rel = relTokens(anchor);
257
+ return rel.includes('external') || rel.includes('no-prefetch');
258
+ }
259
+
260
+ /**
261
+ * Resolve the prefetch strategy for an anchor from a `data-prefetch`
262
+ * attribute. WebJs has no Link component (links are plain `<a href>`), so
263
+ * the knob is a valid-HTML `data-*` attribute, the same shape SvelteKit
264
+ * (`data-sveltekit-preload-data`) and Astro (`data-astro-prefetch`) use.
265
+ * Next.js / Nuxt / Remix express the same choice as a component PROP
266
+ * (`<Link prefetch>`) that never reaches the DOM, so there is nothing to
267
+ * mirror attribute-wise; we reuse their value vocabulary (true/false/auto)
268
+ * as aliases. Default is `intent` (fast-by-default) when the attribute is
269
+ * absent or unrecognised.
270
+ *
271
+ * Value mapping (case-insensitive):
272
+ * - absent / unknown : the DEVICE-ADAPTIVE default (intent on a pointer,
273
+ * viewport on touch); an explicit value always wins
274
+ * - `intent` : hover / focus / touch, after a short dwell
275
+ * - `true` / `render` : eager, as soon as a document scan sees the link
276
+ * - `auto` / `viewport`: on viewport entry (IntersectionObserver), after a dwell
277
+ * - `false` / `none` : never (also via data-no-prefetch / rel="external")
278
+ *
279
+ * The default is adaptive (not a single `intent`) because `intent` does not
280
+ * help on mobile: a touch device has no hover, and `touchstart` fires at tap
281
+ * time, so the prefetch races the navigation. On touch we default to
282
+ * `viewport` (warm links as they settle on-screen) and keep `touchstart` as an
283
+ * extra warm for the tapped link; on a pointer device `intent` stays the
284
+ * default (precise, cheap, a real head-start before the click). A per-link
285
+ * `data-prefetch` always overrides the adaptive default.
286
+ *
287
+ * Returns `none` for suppressed anchors so callers have a single check.
288
+ *
289
+ * @param {Element} anchor
290
+ * @returns {'intent' | 'render' | 'viewport' | 'none'}
291
+ */
292
+ export function prefetchMode(anchor) {
293
+ if (prefetchSuppressed(anchor)) return 'none';
294
+ const raw = (anchor.getAttribute('data-prefetch') || '').toLowerCase().trim();
295
+ switch (raw) {
296
+ case 'false':
297
+ case 'none':
298
+ return 'none';
299
+ case 'true':
300
+ case 'render':
301
+ return 'render';
302
+ case 'auto':
303
+ case 'viewport':
304
+ return 'viewport';
305
+ case 'intent':
306
+ return 'intent';
307
+ default:
308
+ // Unset or unrecognised value: the device-adaptive default.
309
+ return prefetchHasHoverPointer() ? 'intent' : 'viewport';
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Speculatively fetch `href` and stash the server fragment so a later
315
+ * click resolves instantly. No-op when data-saving is on or the entry is
316
+ * already cached or in flight. When the concurrency gate is full the
317
+ * request is QUEUED (not dropped) and drains as in-flight slots free, so
318
+ * a burst of `render` / `viewport` links all eventually prefetch rather
319
+ * than silently losing everything past the cap.
320
+ *
321
+ * @param {string} href
322
+ * @param {string | null} [frameId] The `<webjs-frame>` this href drives, from
323
+ * `resolveTargetFrameId` at the trigger. Sends `x-webjs-frame` so the server
324
+ * answers with the SAME subtree the click will ask for, and keys the entry in
325
+ * that dimension so it can only ever be consumed by a matching frame nav.
326
+ */
327
+ export function prefetch(href, frameId) {
328
+ // Never speculate once the router is torn down: a leftover hover / queue /
329
+ // dwell timer that fires after disableClientRouter must not issue a fetch.
330
+ if (!enabled) return;
331
+ if (typeof fetch !== 'function') return;
332
+ if (prefetchSaysSaveData()) return;
333
+ const key = cacheKey(href, frameId);
334
+ // Never prefetch the page we are already ON (#1106). The request cannot help
335
+ // any future navigation, because a same-URL click short-circuits, and it
336
+ // occupies one of the capped cache slots until its TTL expires. Fires
337
+ // routinely: a hover's intent timer outlives the click it belongs to, so it
338
+ // resolves after the swap has landed and now points at the current page.
339
+ //
340
+ // It ALSO removes one producer of the #1114 stale entry, though it is not the
341
+ // cure for it (the anchor check in prefetchTake is). Worth being precise,
342
+ // because the first version of this fix had the causality backwards: the
343
+ // server short-circuits on LAYOUT segments only and ignores the page's own
344
+ // boundary entry, so a self-prefetch is not a near-empty response, it is a
345
+ // normal fragment anchored at the innermost LAYOUT. That fragment is
346
+ // perfectly applicable from a sibling page and fails only from outside that
347
+ // layout, which is exactly what the anchor check catches, and which a
348
+ // never-clicked hover on a sibling link produces without this guard.
349
+ //
350
+ // Compared in the SAME dimension (#1407): a framed link to the current url is
351
+ // a frame REFRESH, and a refresh must show fresh bytes, so it stays excluded
352
+ // for the reason `fetchAndApply` refuses to let `refresh` consume a prefetch
353
+ // (#1398). Comparing against the bare page key instead would let it through.
354
+ if (typeof location !== 'undefined' && key === cacheKey(location.href, frameId)) return;
355
+ // Every dedupe below keys on the DIMENSIONED key, which is deliberate and has
356
+ // a cost worth stating (#1407). A page holding two links to one href, one
357
+ // driving a frame and one not, warms both dimensions and so issues two
358
+ // requests where it used to issue one, and both occupy a `PREFETCH_CAP` slot.
359
+ // Suppressing the second is the obvious saving and is wrong: the two are
360
+ // DIFFERENT responses, so whichever link lost would never be warmed AHEAD of
361
+ // the click. On touch that is the worse half, because `viewport` is the
362
+ // default there and the only thing left is the `touchstart` warm below, which
363
+ // fires at tap time and so gives a far smaller head start than a dwell would.
364
+ // The duplicate is bounded by the same cap, concurrency gate, TTL, and
365
+ // Save-Data gate as everything else, which is what those are for, and it adds
366
+ // no new TRIGGER and no per-link fan-out.
367
+ if (prefetchInflight.has(key)) return;
368
+ if (prefetchQueued.has(key)) return;
369
+ const existing = prefetchCache.get(key);
370
+ if (existing && (nowMs() - existing.at) < PREFETCH_TTL) return;
371
+ // Refused recently, so re-asking would be refused again (#1407). Bounds the
372
+ // retry to about one request per TTL. Not exactly one: the memo map is itself
373
+ // LRU-capped and nothing ever consumes a memo, so a page with more distinct
374
+ // refused keys than the cap evicts the oldest inside the window.
375
+ if (prefetchRefusedRecently(key)) return;
376
+ if (prefetchInflight.size >= PREFETCH_CONCURRENCY) {
377
+ // Gate full: queue rather than drop, bounded so a huge link list
378
+ // cannot grow the queue without limit (oldest queued entry is shed).
379
+ prefetchQueued.add(key);
380
+ prefetchQueue.push({ href, frameId: frameId || null });
381
+ while (prefetchQueue.length > PREFETCH_QUEUE_CAP) {
382
+ const dropped = prefetchQueue.shift();
383
+ prefetchQueued.delete(cacheKey(dropped.href, dropped.frameId));
384
+ }
385
+ return;
386
+ }
387
+
388
+ const have = buildHaveHeader();
389
+ // #936: while the document is still parsing, the closing `<!--/wj:children-->`
390
+ // marker at the bottom of the body may not exist yet, so `buildHaveHeader()`
391
+ // returns '' meaning "markers not parsed yet", NOT "this page has no layout".
392
+ // A touch-device viewport prefetch fires early enough (mid-parse) to hit this
393
+ // window on real Android Chrome. Caching that empty-`have` response (a full
394
+ // page) would later drive the destructive full-body swap fallback. Skip the
395
+ // speculative fetch; the click path re-fetches with a correct `have` once the
396
+ // document has parsed (and applySwap now falls back to a full load anyway).
397
+ if (!have && typeof document !== 'undefined' && document.readyState === 'loading') return;
398
+
399
+ prefetchInflight.add(key);
400
+ const headers = { 'x-webjs-router': '1', 'x-webjs-prefetch': '1' };
401
+ if (have) headers['x-webjs-have'] = have;
402
+ // Ask for exactly what the click will ask for (`fetch-apply.js` adds the same
403
+ // header on a frame nav), so the cached body is the frame subtree, not the
404
+ // page fragment the swap would refuse to apply.
405
+ if (frameId) headers['x-webjs-frame'] = frameId;
406
+
407
+ // `no-cache` (revalidate, NOT bypass) is load-bearing (#1131): the deploy
408
+ // check below reads x-webjs-build / x-webjs-src off this response, and a
409
+ // page served with a browser max-age would otherwise satisfy the fetch
410
+ // wholly from the HTTP cache, replaying pre-deploy ids. The check would then
411
+ // compare two equally stale values and skip the eviction, so a deploy stayed
412
+ // invisible for the whole freshness window plus one stale-while-revalidate
413
+ // serving per URL. With stable page ETags a forced revalidation is a
414
+ // conditional request answered 304, so the cost is a header round-trip, not
415
+ // a re-download.
416
+ fetch(href, { method: 'GET', headers, credentials: 'same-origin', cache: 'no-cache' })
417
+ .then(async (resp) => {
418
+ const ctype = resp.headers.get('content-type') || '';
419
+ if (!/^text\/html\b/i.test(ctype)) return;
420
+ if (resp.status >= 400) return;
421
+ const build = resp.headers.get('x-webjs-build');
422
+ const src = resp.headers.get('x-webjs-src');
423
+ // Deploy detected at PREFETCH time (#899). A prefetch fetch carries the
424
+ // server's current build id AND app-source id. If EITHER differs from what
425
+ // the page booted with, a deploy landed, so every earlier snapshot/prefetch
426
+ // is pre-deploy and stale. Evict them here, well before the click (a
427
+ // hover/viewport prefetch fires early), so a click on a previously-
428
+ // prefetched link re-fetches fresh (then applySwap hard-reloads on a build
429
+ // change or soft-applies on a src-only change). This shrinks the window
430
+ // where a pre-deploy prefetch, whose stored ids equal the still-old page
431
+ // ids so applySwap alone cannot tell it is stale, is served. Both ids of a
432
+ // pair must be present: an empty id is the warmup "version unknown", never
433
+ // a deploy signal.
434
+ const pageTag = typeof document !== 'undefined' ? document.querySelector('script[type="importmap"]') : null;
435
+ const pageBuild = pageTag ? pageTag.getAttribute('data-webjs-build') : null;
436
+ const pageSrc = pageTag ? pageTag.getAttribute('data-webjs-src') : null;
437
+ if ((build && pageBuild && build !== pageBuild) || (src && pageSrc && src !== pageSrc)) {
438
+ snapshotCache.clear();
439
+ prefetchClearAll(key);
440
+ // A new build can change whether a route streams, so a refusal recorded
441
+ // against the old one says nothing about the new one (#1407).
442
+ clearPrefetchRefused();
443
+ // Deliberately do NOT advance the page's data-webjs-src here (only the
444
+ // foreground `applySwap` does). A prefetch is speculative; leaving the
445
+ // reference id on the old deploy keeps applySwap the single authority
446
+ // that settles the page on the first real navigation. The cost is small:
447
+ // repeated prefetches in the pre-first-nav window each re-clear the
448
+ // (already tiny) caches, which converges the instant the user navigates.
449
+ }
450
+ // The server answers a frame-headed request with the SLICED subtree only
451
+ // when the render did not stream AND the id was in the output
452
+ // (`ssr/render.js`); both fall-throughs return a whole document instead.
453
+ // It marks the sliced case with `x-webjs-frame`, so a mismatch here means
454
+ // the body is not the shape this entry claims. Discard it rather than
455
+ // store it. The swap would NOT splice a whole document into the region
456
+ // (its frame branch queries the body for `webjs-frame#<id>` and diffs
457
+ // only that), but it may find no source at all: an absent id means the
458
+ // render never had the frame, and a streamed page carries its resolved
459
+ // content inside a `<template data-webjs-resolve>` that `querySelector`
460
+ // does not descend into. Then the swap dispatches `webjs:frame-missing`
461
+ // and leaves the region unchanged (warning to the console) while the
462
+ // navigation around it still completes, advancing the url and scrolling to
463
+ // top, so consuming the entry gives the reader a changed address over an
464
+ // unchanged panel rather than a fetch. Whether the frame is found at all
465
+ // depends on where it sits relative to the streamed boundary (one OUTSIDE
466
+ // every boundary does arrive in the first flush and would be found). The
467
+ // router does not LOOK: the refusal is decided on the response header,
468
+ // before the body is ever read, so it declines the whole shape rather than
469
+ // parse a document it has already been told is not the slice it asked for.
470
+ // It cannot be filed under
471
+ // the page key either, since it was fetched with a header the response
472
+ // varies on.
473
+ //
474
+ // Discarding is not the same as FORGETTING, so the refusal is memoed (see
475
+ // `prefetchRefused`) to stop the request being re-issued on every hover
476
+ // until the TTL runs out. The body is still never stored.
477
+ const servedFrame = resp.headers.get('x-webjs-frame');
478
+ if ((servedFrame || null) !== (frameId || null)) {
479
+ notePrefetchRefused(key);
480
+ return;
481
+ }
482
+ const finalUrl = resp.redirected && resp.url ? resp.url : href;
483
+ const html = await resp.text();
484
+ // An eviction landed while this was in flight, so the entry it would
485
+ // store is already superseded (#1407). Drop it rather than resurrect it.
486
+ if (prefetchSuperseded.has(key)) return;
487
+ prefetchStore(key, { html, build, src, finalUrl, frameId: frameId || null, at: nowMs() });
488
+ })
489
+ .catch(() => { /* speculative: swallow */ })
490
+ .finally(() => {
491
+ prefetchInflight.delete(key);
492
+ prefetchSuperseded.delete(key);
493
+ drainPrefetchQueue();
494
+ });
495
+ }
496
+
497
+ /** Start the next queued prefetch if a concurrency slot is free. */
498
+ function drainPrefetchQueue() {
499
+ while (prefetchQueue.length && prefetchInflight.size < PREFETCH_CONCURRENCY) {
500
+ const { href, frameId } = prefetchQueue.shift();
501
+ prefetchQueued.delete(cacheKey(href, frameId));
502
+ prefetch(href, frameId);
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Store a speculative entry under LRU + cap, then announce that the
508
+ * fragment is cached and consumable.
509
+ *
510
+ * The `webjs:prefetch` event fires the instant a speculative fragment
511
+ * becomes consumable (after the response body has been read), which is
512
+ * strictly later than the prefetch request going out. App code can
513
+ * listen to instrument prefetch hit rate, and tests can await it to know
514
+ * a subsequent click will consume the cache rather than refetch. The
515
+ * detail carries the cached URL and a `from: 'prefetch'` tag so a single
516
+ * listener can disambiguate it from `webjs:navigate`.
517
+ *
518
+ * @param {string} key
519
+ * @param {PrefetchEntry} entry
520
+ */
521
+ function prefetchStore(key, entry) {
522
+ if (prefetchCache.has(key)) prefetchCache.delete(key);
523
+ prefetchCache.set(key, entry);
524
+ while (prefetchCache.size > PREFETCH_CAP) {
525
+ const oldest = prefetchCache.keys().next().value;
526
+ prefetchCache.delete(oldest);
527
+ }
528
+ if (typeof document !== 'undefined') {
529
+ document.dispatchEvent(new CustomEvent('webjs:prefetch', {
530
+ detail: { url: entry.finalUrl, key, from: 'prefetch' },
531
+ }));
532
+ }
533
+ }
534
+
535
+ /**
536
+ * The `segment:routeKey` a cached fragment is ANCHORED at, or null when it
537
+ * carries no boundary (a full document, or an unparseable body).
538
+ *
539
+ * A reduced response begins at the deepest boundary the server short-circuited
540
+ * on, so its FIRST open-boundary comment is the join point the swap will look
541
+ * for in the live DOM. Verified against the server: `have=/:/` yields a
542
+ * fragment starting at `<!--wj:children:/:/-->` (anchored at the root, so it
543
+ * applies on any page), while `have=/docs:/docs,/:/` yields one starting at
544
+ * `<!--wj:children:/docs:/docs-->` (applies only under /docs).
545
+ *
546
+ * Read with a regex rather than a parse: this runs on the click path, and the
547
+ * full parse happens moments later in `fetchAndApply` anyway. The pattern is
548
+ * anchored on the literal marker the SSR emits, and a miss is treated as
549
+ * "no constraint", so a shape change degrades to the old permissive behaviour
550
+ * rather than silently rejecting every entry.
551
+ *
552
+ * @param {string} html
553
+ * @returns {string | null}
554
+ */
555
+ export function prefetchAnchor(html) {
556
+ const m = /<!--wj:children:([^>]*?)-->/.exec(html || '');
557
+ return m ? m[1] : null;
558
+ }
559
+
560
+ /**
561
+ * Consume a fresh speculative entry for `href`, removing it (a fragment
562
+ * is single-use: once applied it becomes a real snapshot). Returns null
563
+ * on miss, when the entry has aged past the TTL, or when the live DOM no
564
+ * longer offers the boundary the fragment is anchored at.
565
+ *
566
+ * @param {string} href
567
+ * @param {string} [liveKeysOverride] the `X-Webjs-Have` view to validate the
568
+ * anchor against, when the caller holds a truer one than the live DOM does.
569
+ * Used for the optimistic loading skeleton, which deletes nested boundaries
570
+ * before the fetch, so reading the DOM here would under-report them.
571
+ * @param {string | null} [frameId] Consume only an entry fetched in THIS frame
572
+ * dimension (#1407). A page fragment and a frame subtree for one url are
573
+ * different responses, so they are different entries.
574
+ * @returns {PrefetchEntry | null}
575
+ */
576
+ export function prefetchTake(href, liveKeysOverride, frameId) {
577
+ const key = cacheKey(href, frameId);
578
+ const entry = prefetchCache.get(key);
579
+ if (!entry) return null;
580
+ if ((nowMs() - entry.at) >= PREFETCH_TTL) { prefetchCache.delete(key); return null; }
581
+ // A FRAME entry's validity question is a different one (#1407). Its body is a
582
+ // `<webjs-frame>` subtree with no `wj:children` boundary in it, so
583
+ // `prefetchAnchor` returns null, and null means "no constraint" below: falling
584
+ // through would consume it anywhere. What has to hold instead is that the
585
+ // region it was fetched for is still in the document, which only the live DOM
586
+ // can answer and only at consume time (an outer navigation between the
587
+ // prefetch and the click can remove the frame).
588
+ if (entry.frameId) {
589
+ prefetchCache.delete(key);
590
+ return liveFrameElement(entry.frameId) ? entry : null;
591
+ }
592
+ // The reduced response VARIES on X-Webjs-Have, and this cache is a
593
+ // client-side cache of that response, so it has to respect its own vary
594
+ // dimension (#1114). The dimension is NOT the whole have string though: a
595
+ // fragment is anchored at ONE boundary and applies to any live DOM that still
596
+ // offers that boundary with the same route-key. Checking the whole string
597
+ // instead discards entries that would have applied (prefetch /docs/x from /,
598
+ // soft-nav to /blog, click: the fragment is anchored at the root, which /blog
599
+ // also has), and worse, `applyOptimisticLoading` removes the page's own
600
+ // boundary before the fetch to insert a loading skeleton, so on any route
601
+ // with a `loading.{js,ts}` the live have is legitimately SHORTER at consume
602
+ // time than at prefetch time with no navigation at all.
603
+ //
604
+ // So: consume when the anchor is still live, discard when it is not.
605
+ // Discarding costs one round-trip; consuming a fragment whose anchor is gone
606
+ // hands `applySwap` a tree sharing no boundary with the live DOM, and the
607
+ // #1015 integrity degradation correctly turns that into a full page load,
608
+ // which is the whole-document flash this guard exists to prevent.
609
+ const anchor = prefetchAnchor(entry.html);
610
+ if (anchor) {
611
+ // `buildHaveHeader()` emits exactly comma-joined `segment:routeKey` entries,
612
+ // so it is the single source of truth for the comparison format: membership
613
+ // in it IS "the live DOM offers this boundary with this route-key". It
614
+ // returns '' mid-parse, which rejects an anchored entry, and that is the
615
+ // safe direction (a click during parse takes the full-load path regardless).
616
+ const liveKeys = new Set(
617
+ String(liveKeysOverride != null ? liveKeysOverride : (buildHaveHeader() || ''))
618
+ .split(',').filter(Boolean)
619
+ );
620
+ if (!liveKeys.has(anchor)) {
621
+ prefetchCache.delete(key);
622
+ return null;
623
+ }
624
+ }
625
+ prefetchCache.delete(key);
626
+ return entry;
627
+ }
628
+
629
+ /** Monotonic-ish clock guarded for environments without performance. */
630
+ function nowMs() {
631
+ try {
632
+ if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
633
+ return performance.now();
634
+ }
635
+ } catch { /* ignore */ }
636
+ return 0;
637
+ }
638
+
639
+ /** @param {Event} e */
640
+ export function onPrefetchIntent(e) {
641
+ if (!enabled) return;
642
+ const anchor = closestAnchor(/** @type any */ (e.target));
643
+ if (!anchor) return;
644
+ const mode = prefetchMode(anchor);
645
+ // `none` is suppressed; `render` already prefetched on the document scan.
646
+ if (mode === 'none' || mode === 'render') return;
647
+ const href = eligibleAnchorHref(anchor);
648
+ if (!href) return;
649
+ // touchstart IS the tap: warm the tapped link immediately, for both intent
650
+ // and viewport modes (a single request for a link about to be navigated, the
651
+ // small mobile win the viewport default cannot give for the link just tapped).
652
+ // No dwell, since the tap is the intent.
653
+ if (e.type === 'touchstart') { prefetch(href, resolveTargetFrameId(anchor)); return; }
654
+ // hover / focus only warm `intent` links; `viewport` links are the
655
+ // observer's job (warmed on a dwell, not on a stray hover).
656
+ if (mode !== 'intent') return;
657
+ // pointerover/focusin bubble, so re-entering a child of the same anchor
658
+ // would re-arm; collapse to one timer per anchor.
659
+ if (prefetchHoverAnchor === anchor && prefetchHoverTimer) return;
660
+ clearPrefetchHover();
661
+ prefetchHoverAnchor = anchor;
662
+ prefetchHoverTimer = setTimeout(() => {
663
+ prefetchHoverTimer = null;
664
+ prefetchHoverAnchor = null;
665
+ // Resolved at FIRE time, so it sees the document a click at this moment
666
+ // would (a soft nav during the dwell can change which frame encloses it).
667
+ prefetch(href, resolveTargetFrameId(anchor));
668
+ }, PREFETCH_HOVER_DELAY);
669
+ }
670
+
671
+ /** @param {Event} e */
672
+ export function onPrefetchOut(e) {
673
+ const anchor = closestAnchor(/** @type any */ (e.target));
674
+ if (anchor && anchor === prefetchHoverAnchor) clearPrefetchHover();
675
+ }
676
+
677
+ export function clearPrefetchHover() {
678
+ if (prefetchHoverTimer) { clearTimeout(prefetchHoverTimer); prefetchHoverTimer = null; }
679
+ prefetchHoverAnchor = null;
680
+ }
681
+
682
+ /** Cancel every pending viewport-dwell timer and reset the per-anchor map. */
683
+ export function clearPrefetchViewTimers() {
684
+ for (const timer of prefetchViewPending) clearTimeout(timer);
685
+ prefetchViewPending.clear();
686
+ prefetchViewTimers = new WeakMap();
687
+ }
688
+
689
+ /**
690
+ * (Re)scan the document and apply the non-hover prefetch modes:
691
+ * - `render` anchors prefetch immediately (they are now in the DOM).
692
+ * - `viewport` anchors are observed and prefetch on intersection.
693
+ * `intent` (the default) is handled by the hover/focus/touch listeners,
694
+ * and `none` is skipped. Called on enable and after each navigation,
695
+ * since the swapped-in DOM may carry new links.
696
+ *
697
+ * The viewport threshold (0.5) matches Remix's IntersectionObserver.
698
+ */
699
+ export function refreshPrefetchObservers() {
700
+ if (typeof document === 'undefined') return;
701
+ if (prefetchSaysSaveData()) return;
702
+ const hasIO = typeof IntersectionObserver !== 'undefined';
703
+ if (hasIO) {
704
+ if (!prefetchViewObserver) {
705
+ prefetchViewObserver = new IntersectionObserver((entries) => {
706
+ for (const entry of entries) {
707
+ const anchor = /** @type {Element} */ (entry.target);
708
+ if (entry.isIntersecting) {
709
+ // Arm a dwell timer; the link must STAY on-screen to warm. One
710
+ // timer per anchor, so re-entry while pending does not stack.
711
+ if (prefetchViewTimers.has(anchor)) continue;
712
+ const timer = setTimeout(() => {
713
+ prefetchViewPending.delete(timer);
714
+ prefetchViewTimers.delete(anchor);
715
+ prefetchViewObserver.unobserve(anchor);
716
+ const href = eligibleAnchorHref(anchor);
717
+ if (href && prefetchMode(anchor) === 'viewport') prefetch(href, resolveTargetFrameId(anchor));
718
+ }, PREFETCH_VIEWPORT_DELAY);
719
+ prefetchViewTimers.set(anchor, timer);
720
+ prefetchViewPending.add(timer);
721
+ } else {
722
+ // Scrolled out before the dwell elapsed: cancel, so a fast
723
+ // scroll-through never spends a request.
724
+ const timer = prefetchViewTimers.get(anchor);
725
+ if (timer) {
726
+ clearTimeout(timer);
727
+ prefetchViewPending.delete(timer);
728
+ prefetchViewTimers.delete(anchor);
729
+ }
730
+ }
731
+ }
732
+ }, { threshold: 0.5 });
733
+ } else {
734
+ // Re-scan: drop the old observation set AND cancel any pending dwell
735
+ // timers, so a timer armed for an anchor the soft-nav swap removed cannot
736
+ // fire a prefetch for a stale URL (its exit callback never comes once it
737
+ // is gone). Anchors still on-screen re-arm when observe() below redelivers
738
+ // their current intersection state.
739
+ prefetchViewObserver.disconnect();
740
+ clearPrefetchViewTimers();
741
+ }
742
+ }
743
+ for (const anchor of document.querySelectorAll('a[href]')) {
744
+ const mode = prefetchMode(anchor);
745
+ if (mode === 'render') {
746
+ const href = eligibleAnchorHref(anchor);
747
+ if (href) prefetch(href, resolveTargetFrameId(anchor));
748
+ } else if (mode === 'viewport' && hasIO) {
749
+ prefetchViewObserver.observe(anchor);
750
+ }
751
+ }
752
+ }
753
+
754
+ /**
755
+ * Drop EVERY speculative entry, closing the same in-flight window
756
+ * `prefetchEvict` closes for one key (#1407). Every caller means "what is held
757
+ * predates the change": a mutation applying, a deploy landing, a blanket
758
+ * `revalidate()`. A bare `prefetchCache.clear()` is undone by any speculative
759
+ * fetch that is mid-body-read, which is not hypothetical on the post-mutation
760
+ * path: a hover 100ms earlier is still open when the write applies, and it then
761
+ * stores its PRE-mutation body right after the clear, which is exactly the
762
+ * staleness `revalidate` exists to prevent.
763
+ */
764
+ export function prefetchClearAll(exceptKey) {
765
+ prefetchCache.clear();
766
+ for (const key of prefetchInflight) {
767
+ // Never supersede the fetch that is CALLING this. The deploy-detection
768
+ // branch runs inside a prefetch's own `.then`, and it got there by reading
769
+ // the new build id off its own response, so its bytes are the freshest
770
+ // thing in the process. Marking its key would make it discard its own
771
+ // result and, with it, never announce `webjs:prefetch`.
772
+ if (key === exceptKey) continue;
773
+ prefetchSuperseded.add(key);
774
+ }
775
+ }
776
+
777
+ /**
778
+ * Drop the speculative entry for one url in one dimension (#1407). Used by
779
+ * `loadFrame`: a `<webjs-frame src>` self-load refuses to CONSUME a warm entry
780
+ * because it is a freshness request, and the other half of that is dropping the
781
+ * copy it has just superseded. Without this the self-load paints fresh bytes
782
+ * over the network and leaves an older entry consumable, so the next click on
783
+ * that frame's link repaints the panel with content strictly staler than what is
784
+ * already on screen. `refreshPage` gets the same pairing from `revalidate()`.
785
+ *
786
+ * @param {string} href
787
+ * @param {string | null} [frameId]
788
+ */
789
+ export function prefetchEvict(href, frameId) {
790
+ const key = cacheKey(href, frameId);
791
+ prefetchCache.delete(key);
792
+ // Deleting the stored copy is only half of it: a speculative fetch already IN
793
+ // FLIGHT stores when its body finishes reading, so it would put the entry
794
+ // straight back. Mark it so that store is a no-op instead.
795
+ //
796
+ // A QUEUED prefetch is deliberately left alone. It has not issued its request
797
+ // yet, so when it runs it fetches bytes strictly fresher than the ones this
798
+ // eviction is discarding. Dropping it would prevent no staleness and would
799
+ // leave that link cold with no refusal memo to explain why.
800
+ if (prefetchInflight.has(key)) prefetchSuperseded.add(key);
801
+ }
802
+
803
+ /** Test-only: peek the speculative cache for a href without consuming it. */
804
+ export function _prefetchPeek(href, frameId) { return prefetchCache.get(cacheKey(href, frameId)) || null; }
805
+
806
+ /** Test-only: number of prefetch requests currently in flight. */
807
+ export function _prefetchInflightSize() { return prefetchInflight.size; }
808
+
809
+ /** Test-only: clear all prefetch state between cases. */
810
+ export function _resetPrefetch() {
811
+ prefetchCache.clear();
812
+ prefetchRefused.clear();
813
+ prefetchInflight.clear();
814
+ prefetchSuperseded.clear();
815
+ prefetchQueue.length = 0;
816
+ prefetchQueued.clear();
817
+ clearPrefetchHover();
818
+ clearPrefetchViewTimers();
819
+ }
820
+
821
+ /**
822
+ * Disconnect and drop the viewport observer.
823
+ *
824
+ * `disableClientRouter` tears this down, and the binding lives here because
825
+ * `refreshPrefetchObservers` creates it. An ESM import binding cannot be
826
+ * assigned from another module, so the navigator calls this instead of nulling
827
+ * it directly. The same two statements as before.
828
+ */
829
+ export function teardownPrefetchViewObserver() {
830
+ if (prefetchViewObserver) { prefetchViewObserver.disconnect(); prefetchViewObserver = null; }
831
+ }