@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.
- package/dist/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +20 -20
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -7
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +1 -1
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/component/lifecycle.js +1481 -0
- package/src/component/reactive.js +171 -0
- package/src/component/server-element.js +248 -0
- package/src/component.js +2 -1896
- package/src/render-client/parts.js +2007 -0
- package/src/render-client/reconciler.js +388 -0
- package/src/render-client/template-compiler.js +525 -0
- package/src/render-client.js +1 -2938
- package/src/render-server/attrs.js +160 -0
- package/src/render-server/dsd.js +445 -0
- package/src/render-server/html-scan.js +402 -0
- package/src/render-server/slots.js +213 -0
- package/src/render-server/stream.js +163 -0
- package/src/render-server/template-renderer.js +923 -0
- package/src/render-server/text.js +163 -0
- package/src/render-server.js +6 -2405
- package/src/router-client/anchors.js +44 -0
- package/src/router-client/boundaries.js +221 -0
- package/src/router-client/constants.js +187 -0
- package/src/router-client/diagnostics.js +377 -0
- package/src/router-client/dom-differ.js +709 -0
- package/src/router-client/dom-parse.js +150 -0
- package/src/router-client/events.js +139 -0
- package/src/router-client/fetch-apply.js +387 -0
- package/src/router-client/form-encoder.js +135 -0
- package/src/router-client/frames.js +329 -0
- package/src/router-client/head-merge.js +344 -0
- package/src/router-client/nav-error.js +105 -0
- package/src/router-client/navigator.js +781 -0
- package/src/router-client/prefetch.js +824 -0
- package/src/router-client/scroll.js +259 -0
- package/src/router-client/snapshot-cache.js +97 -0
- package/src/router-client/state.js +92 -0
- package/src/router-client/stream.js +206 -0
- package/src/router-client/swap.js +451 -0
- package/src/router-client/upgrade.js +261 -0
- package/src/router-client/view-transition.js +223 -0
- package/src/router-client.d.ts +10 -1
- package/src/router-client.js +75 -5345
- package/src/slot/assignment.js +304 -0
- package/src/slot/interception.js +558 -0
- package/src/slot/polyfills.js +365 -0
- package/src/slot/project.js +432 -0
- package/src/slot/sensors.js +303 -0
- package/src/slot/state.js +245 -0
- package/src/slot/symbols.js +85 -0
- 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
|
+
}
|