@webjsdev/core 0.7.50 → 0.7.51

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