@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,781 @@
1
+ /**
2
+ * Client router: navigator.
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 { ANCHOR_SUPPRESS_FLOOR_MS } from './constants.js';
10
+ import { reportFallback, reportPreBootNavigation, setNavigating, shouldFullLoadDuringParse } from './diagnostics.js';
11
+ import { applyOptimisticLoading } from './dom-differ.js';
12
+ import { parseHTML } from './dom-parse.js';
13
+ import { onClick, onPopState, onSubmit } from './events.js';
14
+ import { fetchAndApply } from './fetch-apply.js';
15
+ import { clearFormBusy, markFormBusy } from './frames.js';
16
+ import { clearPrefetchHover, clearPrefetchRefused, clearPrefetchViewTimers, onPrefetchIntent, onPrefetchOut, prefetchCache, prefetchClearAll, prefetchEvict, refreshPrefetchObservers, teardownPrefetchViewObserver } from './prefetch.js';
17
+ // `restoreGeneration` is imported READ-ONLY: the deferred restore captures it
18
+ // and re-compares after the frame, so it must be the live binding. Writes go
19
+ // through bumpRestoreGeneration(), since ESM forbids assigning an import.
20
+ import { afterTwoFrames, bumpRestoreGeneration, cancelScrollCatchUp, catchUpToRestoredScroll, releaseScrollAnchor, restoreGeneration, suppressScrollAnchoring } from './scroll.js';
21
+ import { snapshotCache, snapshotCurrent, snapshotGet } from './snapshot-cache.js';
22
+ import { _setEnabled, bumpNavToken, currentNavigationToken, enabled, hardNavigate } from './state.js';
23
+ import { _swapCommit, applySwap } from './swap.js';
24
+ import { ensureUpgradeObserver } from './upgrade.js';
25
+ import { viewTransitionsEnabled } from './view-transition.js';
26
+
27
+
28
+ /**
29
+ * AbortController for the currently in-flight fetch. A new navigation /
30
+ * submission `abort()`s this and replaces it: Turbo Drive's
31
+ * `navigator.stop()` pattern. Aborting in-flight requests on rapid
32
+ * link clicks avoids late responses clobbering newer settled state.
33
+ *
34
+ * @type {AbortController | null}
35
+ */
36
+ let activeAbortController = null;
37
+
38
+ /**
39
+ * The URL the user is currently viewing: tracked separately from
40
+ * `location.href` because on `popstate` the browser updates
41
+ * `location.href` to the destination URL BEFORE firing the event,
42
+ * which means snapshotting "the current page" naively keys against
43
+ * the wrong URL (the page being arrived at, not the page being left).
44
+ *
45
+ * Updated after every successful navigation completes. Used by
46
+ * `snapshotCurrent` to key the snapshot under the URL the user is
47
+ * actually leaving.
48
+ *
49
+ * @type {string | null}
50
+ */
51
+ let currentPageUrl = null;
52
+
53
+ /**
54
+ * Previous value of `history.scrollRestoration` (so we can restore it
55
+ * when the router is disabled). The browser's default behavior of
56
+ * auto-restoring scroll on popstate races with the SPA's own scroll
57
+ * restoration: disabled here so WebJs is the sole authority on scroll
58
+ * during navigation. Same pattern as Turbo Drive's
59
+ * `assumeControlOfScrollRestoration()` (turbo/src/core/drive/history.js).
60
+ *
61
+ * @type {ScrollRestoration | null}
62
+ */
63
+ export let prevScrollRestoration = null;
64
+
65
+ /** Enable the client router. Idempotent. */
66
+ export function enableClientRouter() {
67
+ if (enabled || typeof document === 'undefined') return;
68
+ _setEnabled(true);
69
+ // Publish the in-place refresh entry on the global (#1398). The dev
70
+ // live-reload client is a separate served script with no import of this
71
+ // module, so a global is the only seam it has, and its PRESENCE is the
72
+ // feature detection: an app that opted out with `webjs.clientRouter: false`
73
+ // never calls this, and a page that ships no component never loads
74
+ // @webjsdev/core at all, so both no-router cases resolve to a full reload
75
+ // without either side assuming anything about the other.
76
+ /** @type any */ (globalThis).__webjsRefreshPage = refreshPage;
77
+ // Both `click` and `submit` are BUBBLE phase, not capture. A component's
78
+ // per-element `@click` / `@submit` handler (render-client.js) runs in the
79
+ // at-target phase, BEFORE a document-level bubble listener. So onClick /
80
+ // onSubmit run AFTER the component, and their `if (e.defaultPrevented) return`
81
+ // guard sees the component's `preventDefault` and leaves the element alone.
82
+ // A capture listener would run FIRST, before the component, so the guard
83
+ // would always see `false` and the router would wrongly hijack a JS-handled
84
+ // link or form: navigate a `<a @click=${e => e.preventDefault()}>` away, or
85
+ // submit a `<form @submit=${e => e.preventDefault()}>` (the live chat /
86
+ // comments forms, which preventDefault and send over WebSocket / fetch),
87
+ // navigating the page out from under it. All the phase-independent filtering
88
+ // (modifier / middle clicks, downloads, cross-origin, hash links, GET-vs-POST)
89
+ // happens inside onClick / onSubmit regardless of phase. Mirrors
90
+ // hotwired/turbo, which does its interception work in bubble listeners.
91
+ document.addEventListener('click', onClick, false);
92
+ document.addEventListener('submit', onSubmit, false);
93
+ window.addEventListener('popstate', onPopState);
94
+ // Intent prefetch: warm the next page on hover / focus / touch-start.
95
+ // pointerover + focusin bubble, so one delegated listener each covers
96
+ // the whole document, including links added by later navigations.
97
+ document.addEventListener('pointerover', onPrefetchIntent, true);
98
+ document.addEventListener('focusin', onPrefetchIntent, true);
99
+ document.addEventListener('touchstart', onPrefetchIntent, { capture: true, passive: true });
100
+ document.addEventListener('pointerout', onPrefetchOut, true);
101
+ // After every client navigation the swapped-in DOM may carry new
102
+ // anchors, so re-scan for render/viewport modes. webjs:navigate fires
103
+ // at the end of fetchAndApply for both link and frame swaps.
104
+ document.addEventListener('webjs:navigate', refreshPrefetchObservers);
105
+ ensureUpgradeObserver();
106
+ // Apply render/viewport prefetch modes to the initial document.
107
+ refreshPrefetchObservers();
108
+ // Take control of scroll restoration so the browser doesn't fight
109
+ // the SPA's own snapshot-based restore on popstate.
110
+ if (typeof history !== 'undefined' && 'scrollRestoration' in history) {
111
+ prevScrollRestoration = history.scrollRestoration;
112
+ history.scrollRestoration = 'manual';
113
+ }
114
+ // Seed the "current page" tracker so the first navigation can
115
+ // snapshot the page the user is leaving.
116
+ if (typeof location !== 'undefined') currentPageUrl = location.href;
117
+ // Last, once the listeners are on: report whether the load that got us here
118
+ // was a same-origin navigation the router never saw (#1118). Running it after
119
+ // the listeners means a throw inside a diagnostic can never leave the router
120
+ // half-installed.
121
+ reportPreBootNavigation();
122
+ }
123
+
124
+ /** Disable the client router. */
125
+ export function disableClientRouter() {
126
+ if (!enabled) return;
127
+ _setEnabled(false);
128
+ document.removeEventListener('click', onClick, false);
129
+ document.removeEventListener('submit', onSubmit, false);
130
+ window.removeEventListener('popstate', onPopState);
131
+ document.removeEventListener('pointerover', onPrefetchIntent, true);
132
+ document.removeEventListener('focusin', onPrefetchIntent, true);
133
+ document.removeEventListener('touchstart', onPrefetchIntent, /** @type any */ ({ capture: true }));
134
+ document.removeEventListener('pointerout', onPrefetchOut, true);
135
+ document.removeEventListener('webjs:navigate', refreshPrefetchObservers);
136
+ clearPrefetchHover();
137
+ clearPrefetchViewTimers();
138
+ teardownPrefetchViewObserver();
139
+ if (typeof history !== 'undefined' && prevScrollRestoration !== null) {
140
+ history.scrollRestoration = prevScrollRestoration;
141
+ prevScrollRestoration = null;
142
+ }
143
+ // Never leave a restore window open on <html>, nor a catch-up chasing a
144
+ // scroll offset after the router is gone (#1310).
145
+ bumpRestoreGeneration();
146
+ if (releaseScrollAnchor) releaseScrollAnchor();
147
+ if (cancelScrollCatchUp) cancelScrollCatchUp();
148
+ currentPageUrl = null;
149
+ // Unpublish the refresh entry (#1398) so the dev reload client's feature
150
+ // detection sees the router is gone and falls back to a full reload.
151
+ delete (/** @type any */ (globalThis).__webjsRefreshPage);
152
+ }
153
+
154
+ /**
155
+ * Programmatic navigation (replaces `location.href = url`).
156
+ * @param {string} url
157
+ * @param {{ replace?: boolean }} [opts]
158
+ */
159
+ export async function navigate(url, opts) {
160
+ const target = new URL(url, location.href);
161
+ if (target.origin !== location.origin) {
162
+ // Cross-origin: an intentional full-page nav, not a degradation, but it
163
+ // ends the session in a test just the same, so it rides the same seam.
164
+ hardNavigate(url);
165
+ return;
166
+ }
167
+ await performNavigation(target.href, opts?.replace ?? false, null);
168
+ }
169
+
170
+ /**
171
+ * Self-load a `<webjs-frame src>`: fetch `url` as a frame nav and apply the
172
+ * matching `<webjs-frame id>` subtree into `frameEl` through the EXACT same
173
+ * frame-swap path a click-driven frame nav uses (`fetchAndApply` with the
174
+ * frame's id). So the #252 `aria-busy` lifecycle + `webjs:frame-busy` events,
175
+ * the #249 `webjs:navigation-error` recovery, the keyed reconciler, and the
176
+ * `webjs:frame-missing` fallback all apply for free; a `src` self-load and a
177
+ * click that targets the same frame produce identical DOM.
178
+ *
179
+ * This is NOT a page navigation: it records no history entry, takes no page
180
+ * snapshot, and shows no optimistic loading skeleton (it swaps one region, not
181
+ * the page). It also stands entirely outside the speculative prefetch cache
182
+ * (#1407): it EVICTS the warm entry for this url in this frame's dimension and
183
+ * passes `noPrefetch` so it cannot consume one, because a `src` self-load or
184
+ * `src` mutation is the app asking for THIS frame's content now, a freshness
185
+ * request rather than a hover being followed. Refusing without evicting would
186
+ * leave a copy this load is about to supersede, so a later click on that
187
+ * frame's link would repaint it with staler bytes than are already on screen.
188
+ * The evict half runs only once the swap has COMMITTED, so a load that aborts,
189
+ * fails, or hits `webjs:frame-missing` leaves the warm entry alone: the frame
190
+ * still shows its old content, which that entry still matches.
191
+ *
192
+ * It runs under a fresh nav token + AbortController so it interleaves
193
+ * safely with real navigations and with a superseding `src` change on the same
194
+ * frame (the later load's token wins; the earlier one's teardown never clears
195
+ * the newer load's busy state, see `frameBusyTokens`).
196
+ *
197
+ * Called only by `<webjs-frame>` itself (`webjs-frame.js`), which owns the
198
+ * no-double-load guard (eager connect vs lazy-viewport vs a `src` mutation).
199
+ *
200
+ * @param {Element} frameEl The live `<webjs-frame>` element to fill.
201
+ * @param {string} url The `src` value, resolved against `location.href`.
202
+ * @returns {Promise<{ ok: boolean, status: number | null, aborted: boolean, applied: boolean }>}
203
+ * Passed straight through from `fetchAndApply`, so `applied` says whether the
204
+ * frame subtree was actually swapped (#1398). Each guard below returns it
205
+ * explicitly rather than leaving the field off: one path omitting it would be
206
+ * the same kind of contract hole the flag exists to close.
207
+ */
208
+ export async function loadFrame(frameEl, url) {
209
+ if (typeof location === 'undefined') return { ok: false, status: null, aborted: false, applied: false };
210
+ const id = frameEl && /** @type any */ (frameEl).id;
211
+ if (!id) return { ok: false, status: null, aborted: false, applied: false };
212
+ const target = new URL(url, location.href);
213
+ // Cross-origin can't be a same-document frame swap (and a frame fetch must
214
+ // send a same-origin credentialed request). Leave the frame unchanged.
215
+ if (target.origin !== location.origin) return { ok: false, status: null, aborted: false, applied: false };
216
+
217
+ // A frame self-load shares the global abort + token machinery so a real
218
+ // navigation that starts mid-load supersedes it (and vice versa), exactly
219
+ // like a click-driven frame nav routed through performNavigation.
220
+ if (activeAbortController) activeAbortController.abort();
221
+ activeAbortController = new AbortController();
222
+ const signal = activeAbortController.signal;
223
+ const myToken = bumpNavToken();
224
+
225
+ const outcome = await fetchAndApply(
226
+ target.href,
227
+ id,
228
+ /* recordHistory */ false,
229
+ /* optimisticState */ null,
230
+ 'GET',
231
+ /* body */ null,
232
+ signal,
233
+ myToken,
234
+ /* revalidating */ false,
235
+ /* refresh */ undefined,
236
+ // A self-load never consumes a speculative entry (#1407). Dropping the
237
+ // blanket `!frameId` guard from the consume check made this path eligible
238
+ // for the first time, and it should not be: a `src` self-load or a `src`
239
+ // mutation is the app asking for THIS frame's content now, which is a
240
+ // freshness request, not the click-follows-hover shape the warm cache
241
+ // exists to serve. Deliberately out of scope for that change.
242
+ /* noPrefetch */ true,
243
+ );
244
+
245
+ // A self-load is a freshness request, so it does BOTH halves (#1407): it
246
+ // declines to consume a warm entry (above), and it drops the copy it has
247
+ // superseded. Only once the swap actually COMMITTED, though, which `applied`
248
+ // is exactly the question for (`ok` is not, since a 422 or a notFound() page
249
+ // still commits). The clearest case is a `webjs:frame-missing` or an abort by
250
+ // a newer nav: the frame keeps showing its old content, which the warm entry
251
+ // still matches, so evicting would cost the next click a round trip for
252
+ // nothing. A transport failure or a non-HTML body do NOT leave the page
253
+ // untouched (`handleNavigationError` either swaps an error alert into the
254
+ // deepest layout range or hard-navigates), but they commit nothing for this
255
+ // FRAME either, so the same gate is right for them by the same rule.
256
+ if (outcome && outcome.applied) prefetchEvict(target.href, id);
257
+ return outcome;
258
+ }
259
+
260
+ /**
261
+ * Invalidate a cached snapshot. Call after a server action mutates data
262
+ * that affects a cached page so the next visit refetches.
263
+ *
264
+ * Evicts BOTH the back/forward snapshot cache and the speculative
265
+ * prefetch cache. A prefetched fragment captured before a mutation would
266
+ * otherwise be served stale on the next forward click, the same staleness
267
+ * the snapshot eviction prevents for back/forward.
268
+ *
269
+ * @param {string} [url] Specific URL to invalidate, or omit to clear all.
270
+ */
271
+ export function revalidate(url) {
272
+ // Falsy `url` (undefined, null, empty string) clears everything.
273
+ // Loose `== null` would have left `revalidate('')` to silently no-op,
274
+ // because `new URL('', location.href)` is a valid relative URL and the
275
+ // resulting cache key rarely matches anything.
276
+ // Neither form touches the refusal memos (#1407). This is the post-MUTATION
277
+ // api an app calls after an RPC write, so clearing here would drop every memo
278
+ // on every mutation and reopen the request-per-hover loop the memo exists to
279
+ // close. A mutation CAN change a render's streamed shape, since a page may
280
+ // render `Suspense` conditionally on fetched data, but a memo that outlives
281
+ // that costs one skipped warm-up for that key until the TTL, which is the
282
+ // cheaper side of the trade. `refreshPage`, where the SOURCE may have changed,
283
+ // clears them itself.
284
+ if (!url) { snapshotCache.clear(); prefetchClearAll(); return; }
285
+ const u = new URL(url, location.href);
286
+ const key = u.pathname + u.search;
287
+ snapshotCache.delete(key);
288
+ // EVERY dimension of that url, not just the page one (#1407). A frame link's
289
+ // entry is keyed `<frameId> <path>`, so deleting the bare path would leave a
290
+ // pre-mutation frame subtree consumable by the next frame click for the rest
291
+ // of its TTL, which is the exact staleness this function exists to prevent.
292
+ // The path half can never contain a space (the URL parser percent-encodes
293
+ // one), so a trailing-space match identifies the framed keys unambiguously.
294
+ prefetchCache.delete(key);
295
+ const framedSuffix = ` ${key}`;
296
+ for (const k of [...prefetchCache.keys()]) {
297
+ if (k.endsWith(framedSuffix)) prefetchCache.delete(k);
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Re-render the CURRENT url on the server and apply it in place, with no page
303
+ * reload (#1398).
304
+ *
305
+ * Dev-facing today, since the live-reload client calls it for a page or layout
306
+ * edit, but it is a plain capability with no dev-only code in it.
307
+ *
308
+ * It records no history entry and never scrolls, so the reader keeps their
309
+ * place and Back still goes to the previous page. `mode` picks the swap tier:
310
+ *
311
+ * - `'page'` morphs the deepest shared boundary, so the outer layout's DOM and
312
+ * the hydrated state of its components survive. This is the light tier and
313
+ * the default.
314
+ * - `'shell'` replaces the whole body. Needed when the LAYOUT's own markup
315
+ * changed, because that markup lives outside every children range and a
316
+ * boundary morph would leave it untouched. Component instances do not survive
317
+ * it.
318
+ *
319
+ * It does NOT reload changed component modules, and cannot: `customElements
320
+ * .define` is once-per-tag and a module URL is fetched once per document. A
321
+ * caller whose change touched browser code must reload instead.
322
+ *
323
+ * @param {'page' | 'shell'} [mode]
324
+ * @returns {Promise<boolean>} whether the refresh applied. `false` means the
325
+ * caller should fall back to a full load.
326
+ */
327
+ export async function refreshPage(mode) {
328
+ if (!enabled || typeof location === 'undefined') return false;
329
+ // Every cached copy predates the change, so drop both caches before fetching.
330
+ revalidate();
331
+ // And the refusal memos (#1407), which `revalidate` deliberately leaves alone
332
+ // because it is also the post-mutation api. Here the SOURCE changed, and an
333
+ // edit can start or stop a route streaming, which is what produced the
334
+ // unmarked answer a memo recorded. What reaches here is an edit to a file the
335
+ // browser does NOT download: a page gaining or losing a `Suspense(...)`
336
+ // boundary is the plain case, since rendering one does not by itself make the
337
+ // page client-effecting, so it classifies `page` rather than a reload. An edit
338
+ // to anything that SHIPS reloads instead and never arrives, which covers a
339
+ // `loading.{js,ts}` either direction (it can never be elided, so it is always
340
+ // shipped, and a brand-new one is in no graph at all).
341
+ clearPrefetchRefused();
342
+ try {
343
+ const res = await performNavigation(location.href, false, null, { refresh: mode === 'shell' ? 'shell' : 'page' });
344
+ // The outcome has to be READ, not inferred from the absence of a throw.
345
+ // `fetchAndApply` reports every real failure as `{ applied: false }` and
346
+ // throws for none of them (a rejected fetch, a non-HTML body, an
347
+ // unparseable one), so a bare try/catch here would resolve `true` for
348
+ // exactly the cases the caller's full-load fallback exists to cover, and
349
+ // the page would silently sit on stale content.
350
+ //
351
+ // It reads `applied`, NOT `ok`. An HTML body of any status is swapped in
352
+ // place, so a page rendered through `notFound()`, `forbidden()`, or an
353
+ // `error.ts` boundary is `ok: false` and applied perfectly well. Reading
354
+ // `ok` would report those as failures and make the dev client reload on top
355
+ // of a swap that already happened, losing the state this exists to keep,
356
+ // every time you iterate on a page that currently renders a 4xx or 5xx.
357
+ //
358
+ // Two outcomes are NOT failures either. A `null` means no fetch decided it
359
+ // (the parse guard hard-navigated, or a popstate restored a snapshot), so
360
+ // the page is already resolved. An `aborted` means a newer navigation
361
+ // superseded this one and owns the page now, and reloading would yank the
362
+ // reader out of it; Turbo drops a refresh that lands mid-navigation for the
363
+ // same reason.
364
+ if (!res || res.aborted) return true;
365
+ return res.applied;
366
+ } catch (_) {
367
+ return false;
368
+ }
369
+ }
370
+
371
+ // Auto-enable on import: deferred to the END of this module (see the
372
+ // call after the test-only exports). enableClientRouter() transitively
373
+ // reads the prefetch state (prefetchViewObserver and the caches), which
374
+ // are `const`/`let` declared lower in the file and therefore in the
375
+ // temporal dead zone here. Calling enable at module-end, after every
376
+ // top-level binding is initialised, avoids a ReferenceError in the
377
+ // bundled browser build.
378
+
379
+ /* ====================================================================
380
+ * Click + popstate handlers
381
+ * ==================================================================== */
382
+
383
+ /**
384
+ * @param {string} href
385
+ * @param {boolean} isPopState
386
+ * @param {string | null} frameId Active <webjs-frame> id, or null.
387
+ * @param {{ refresh?: 'page' | 'shell' }} [opts] `refresh` marks a same-URL
388
+ * in-place re-render (#1398). It suppresses three things a forward navigation
389
+ * does and a refresh must not: the outgoing snapshot, the optimistic loading
390
+ * skeleton, and history plus scroll. See `refreshPage`.
391
+ * @returns {Promise<{ ok: boolean, status: number | null, aborted: boolean, applied: boolean } | null>}
392
+ * The `fetchAndApply` outcome, so a caller can tell an applied navigation from
393
+ * a failed one (#1398). Read `applied` rather than `ok` for that question: an
394
+ * HTML body of any status is swapped in place, so a rendered 404 or 500 is
395
+ * `ok: false` and `applied: true`. `null` means no fetch decided the outcome:
396
+ * the parse guard hard-navigated, or a popstate restored from the snapshot
397
+ * cache. Both already resolved the page, so a caller must not treat `null` as
398
+ * a failure. Every other caller ignores the value.
399
+ */
400
+ export async function performNavigation(href, isPopState, frameId, opts) {
401
+ const refresh = (opts && opts.refresh) || undefined;
402
+ // #1008 / #936: a forward, main-document nav fired while the document is
403
+ // still parsing (`readyState === 'loading'`) races the DOM. The leaving
404
+ // page's closing layout markers at the bottom of the body may not exist yet,
405
+ // so `snapshotCurrent` plus region discovery would capture an incomplete tree
406
+ // and drive a corrupt or over-wide swap (the suspected root cause of the
407
+ // dropped-marker reports). The PREFETCH path already skips this window (see
408
+ // the `buildHaveHeader` call site); the click / `navigate()` path did not.
409
+ // Degrade to a correct full-page load, which is what an MPA would do anyway.
410
+ // Scoped to frameless forward navs: popstate is browser-driven, and a frame
411
+ // nav carries its own boundary element.
412
+ if (shouldFullLoadDuringParse(isPopState, frameId) && typeof location !== 'undefined') {
413
+ reportFallback('readyState-loading', href);
414
+ hardNavigate(href);
415
+ return null;
416
+ }
417
+
418
+ // Cancel any in-flight fetch: Turbo Drive's navigator.stop().
419
+ if (activeAbortController) activeAbortController.abort();
420
+ activeAbortController = new AbortController();
421
+ const signal = activeAbortController.signal;
422
+ // Bump nav generation. Captured below + by anything we await into.
423
+ const myToken = bumpNavToken();
424
+
425
+ // A new navigation ends any restore window still open from an earlier one
426
+ // (#1310). The window outlives its own restore by design (a floor, then a
427
+ // ceiling), so without this a second navigation inside that span inherits
428
+ // suppressed anchoring: a Back that CLAMPS opens no window of its own, so it
429
+ // would run the whole growth under the previous restore's suppression and
430
+ // freeze its clamp, and a forward nav would carry it onto a different page
431
+ // entirely. Reopening for this navigation, if it earns one, happens below.
432
+ // The clamped path's catch-up is cancelled for the same reason: it chases an
433
+ // offset recorded for the page being navigated away from.
434
+ //
435
+ // A FRAME-targeted nav is excluded, for the same reason `loadFrame` is: it
436
+ // swaps one region and leaves the page, and so the restored scroll offset,
437
+ // intact. The codebase already treats a click-driven frame nav and a `src`
438
+ // self-load as the same thing, so exempting one and not the other would be
439
+ // the split this rule exists to avoid.
440
+ //
441
+ // All THREE move together. Exempting only the counter while still closing
442
+ // the window and aborting the catch-up would leave the split exactly where
443
+ // it was, one line further down: a form inside a frame, submitted by a
444
+ // component upgrading in the just-restored page, would hand anchoring back
445
+ // mid-restore and bring the whole double-count back.
446
+ if (!frameId) {
447
+ bumpRestoreGeneration();
448
+ if (releaseScrollAnchor) releaseScrollAnchor();
449
+ if (cancelScrollCatchUp) cancelScrollCatchUp();
450
+ }
451
+
452
+ // Snapshot the page the user is LEAVING (with its scroll position)
453
+ // so back/forward navigation can restore it. We key under
454
+ // `currentPageUrl` rather than `location.href` because on popstate
455
+ // the browser has already updated `location.href` to the destination
456
+ // URL: using it as the key would clobber the cached snapshot we're
457
+ // about to read in the popstate-restore branch below.
458
+ //
459
+ // A refresh (#1398) skips it: it navigates to the URL it is already on, so
460
+ // snapshotting first would write the PRE-EDIT page into `snapshotCache` under
461
+ // that exact key and a later Back would restore the stale render.
462
+ if (!refresh && currentPageUrl) snapshotCurrent(currentPageUrl);
463
+
464
+ // Expose the opt-in `data-navigating` loading-indicator hook (see
465
+ // setNavigating), but only if the nav takes long enough to be worth showing
466
+ // one. Deferred so quick navs (sub-150ms) never set it at all.
467
+ let navigatingFlagTimer = setTimeout(() => {
468
+ setNavigating(true);
469
+ navigatingFlagTimer = null;
470
+ }, 150);
471
+
472
+ // Optimistic loading: clone the per-segment loading.ts template (if
473
+ // any) into the deepest current children-slot so the user sees an
474
+ // instant skeleton instead of stale content. Saved so we can restore
475
+ // it if the fetch fails.
476
+ //
477
+ // A refresh (#1398) shows none: flashing a `loading.ts` skeleton over content
478
+ // that is already correct is strictly worse than showing the old content for
479
+ // one round trip. Turbo's `MorphingPageRenderer` suppresses its own
480
+ // equivalents for the same reason.
481
+ let optimisticState = null;
482
+ if (!isPopState && !refresh) optimisticState = applyOptimisticLoading();
483
+
484
+ try {
485
+ // popstate: try cache first, then refetch in background. Instant restore.
486
+ if (isPopState) {
487
+ const cached = snapshotGet(href);
488
+ if (cached) {
489
+ const cachedDoc = parseHTML(cached.html);
490
+ if (cachedDoc) {
491
+ applySwap(cachedDoc, frameId, /* revalidating */ true, /* href */ null);
492
+ // Restore window scroll to where the user left it. Use
493
+ // behavior:'instant' so an app-level `scroll-behavior: smooth`
494
+ // stylesheet does not animate the restore (native nav jumps).
495
+ //
496
+ // `cached.scrollY` was recorded at the page's SETTLED height, and the
497
+ // DOM just swapped in is still shorter until its components upgrade
498
+ // and re-render. Suppress scroll anchoring across the restore, or the
499
+ // browser adds that late growth to the restored offset and the reader
500
+ // lands below where they left (#1310).
501
+ let releaseAnchor = () => {};
502
+ if (typeof window !== 'undefined') {
503
+ // Restore the scroll, then decide whether to suppress anchoring.
504
+ //
505
+ // Suppress ONLY when the recorded offset was actually reached. A
506
+ // document that has not grown yet can be too SHORT to scroll that
507
+ // far, and the browser clamps to its current maximum. A reader at
508
+ // the bottom of the settled page is the clear case: the shortfall is
509
+ // then exactly the growth still to come, and anchoring ADDING that
510
+ // growth is what carries them back to the bottom. Suppressing there
511
+ // freezes the clamp instead and strands them a full page-growth
512
+ // ABOVE where they left, which is this bug's own mirror image. The
513
+ // two situations want opposite things and are told apart by the one
514
+ // question that separates them: did the scroll land.
515
+ //
516
+ // Both halves must read the SAME layout, and the scroll must be
517
+ // written against the page being restored. That is why this is
518
+ // ordered rather than simply inlined, and why the ordering differs
519
+ // by path.
520
+ const restoreScroll = () => {
521
+ window.scrollTo({ left: cached.scrollX, top: cached.scrollY, behavior: 'instant' });
522
+ if (window.scrollY >= cached.scrollY - 1) {
523
+ releaseAnchor = suppressScrollAnchoring();
524
+ } else {
525
+ // Clamped. Anchoring is left on, since it is what carries the
526
+ // reader back down, but it adds the FULL growth regardless of
527
+ // how far short the clamp fell, so on its own it only lands a
528
+ // reader who left at the very bottom. Chase the recorded offset
529
+ // instead, once the page is tall enough to hold it.
530
+ catchUpToRestoredScroll(cached.scrollY, cached.scrollX);
531
+ }
532
+ };
533
+ if (viewTransitionsEnabled() && typeof (/** @type any */ (document)).startViewTransition === 'function') {
534
+ // Under a view transition `applySwap` defers its DOM mutation a
535
+ // frame, so running now would write and measure against the
536
+ // OUTGOING page. Measured with a 60000px outgoing page and a
537
+ // 3000px restored one: the scroll "landed" at 20000, suppression
538
+ // opened, and the restored page then clamped to 2416 with
539
+ // anchoring held off, which is precisely the stranding the
540
+ // conditional exists to prevent. Wait for the swap to commit.
541
+ //
542
+ // Guarded, because this is the one path where the restore
543
+ // outlives the call that scheduled it. Every cancel site in this
544
+ // feature (performNavigation, performSubmission,
545
+ // disableClientRouter) runs at the START of the next thing, so a
546
+ // navigation, submission, or disable arriving inside the deferred
547
+ // frame would close the window and then have this reopen it,
548
+ // scrolling a page it was never meant for to an offset recorded
549
+ // for the previous history entry. The synchronous branch below
550
+ // cannot outlive anything and so needs no guard.
551
+ const myRestore = restoreGeneration;
552
+ _swapCommit.then(() => {
553
+ if (myRestore !== restoreGeneration || !enabled) return;
554
+ restoreScroll();
555
+ }).catch(() => {});
556
+ } else {
557
+ // The synchronous path, and the read must STAY synchronous here.
558
+ // Deferring it even by a microtask breaks the fix outright: by
559
+ // then the restored components' renders have been applied, and
560
+ // reading `scrollY` forces the layout that flushes them, so
561
+ // anchoring runs DURING the read and hands back the
562
+ // already-shifted offset. Measured on /ui/button, the suppression
563
+ // landed 19ms late with `scrollY` already 800 -> 1563. What makes
564
+ // it correct is not which document it sees but that it sees the
565
+ // same layout the scroll just landed in.
566
+ restoreScroll();
567
+ }
568
+ }
569
+ // Fire-and-forget revalidation. Uses a fresh AbortController
570
+ // since this background fetch is allowed to overlap with the
571
+ // next foreground nav (it'll get aborted if a new nav lands).
572
+ //
573
+ // Closing the anchoring window on THIS revalidation's settle (plus two
574
+ // frames for the re-applied DOM to lay out) is what keeps the window
575
+ // tied to one restore. A height observer could not tell a settling
576
+ // restore from a streaming <webjs-suspense> boundary (#471 / #473).
577
+ //
578
+ // The floor is what makes that safe. Waiting on the revalidation ALONE
579
+ // ties the window's length to network latency rather than to the
580
+ // growth it guards, so a server that answers faster than the restored
581
+ // page renders closes it early and the reader lands low again, which
582
+ // is the whole defect.
583
+ const revalidated = fetchAndApply(href, frameId, /* recordHistory */ false, optimisticState, 'GET', null, signal, myToken, /* revalidating */ true)
584
+ .catch(() => {});
585
+ const floor = new Promise((r) => setTimeout(r, ANCHOR_SUPPRESS_FLOOR_MS));
586
+ Promise.all([revalidated, floor]).then(() => afterTwoFrames(releaseAnchor));
587
+ return null;
588
+ }
589
+ }
590
+ // Cache-miss popstate. Browser-native scroll restoration is
591
+ // disabled (we set scrollRestoration='manual'): so without
592
+ // explicit handling, scroll would just stay where the user was
593
+ // on the page they popped FROM. Scroll to top as the reasonable
594
+ // default; fetchAndApply skips its own scroll handling when
595
+ // recordHistory=false (which is the case here).
596
+ if (typeof window !== 'undefined') window.scrollTo({ left: 0, top: 0, behavior: 'instant' });
597
+ }
598
+
599
+ // `recordHistory: false` on a refresh (#1398) is what suppresses BOTH the
600
+ // duplicate `history.pushState` and the whole scroll block in one flag,
601
+ // which is exactly what the comment above that block already says it means.
602
+ // So Back still goes to the previous page and the reader keeps their place.
603
+ return await fetchAndApply(href, frameId, !isPopState && !refresh, optimisticState, 'GET', null, signal, myToken, /* revalidating */ false, refresh);
604
+ } finally {
605
+ if (navigatingFlagTimer) clearTimeout(navigatingFlagTimer);
606
+ // Only clear the navigating flag if WE are still the active nav.
607
+ // A newer nav has its own flag lifecycle.
608
+ if (myToken === currentNavigationToken) {
609
+ setNavigating(false);
610
+ // Record where the user is NOW so the next navigation can
611
+ // snapshot under the right URL key.
612
+ if (typeof location !== 'undefined') currentPageUrl = location.href;
613
+ }
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Submit a form via the partial-swap pipeline. Mirrors performNavigation
619
+ * but routes the FormData body. GET submissions promote the body to a
620
+ * query string (HTML form-submission algorithm); non-GET submissions
621
+ * send the body as-is.
622
+ *
623
+ * Mutating methods (anything except GET/HEAD) clear the whole snapshot
624
+ * cache after a successful response: Turbo's `clearSnapshotCache()` on
625
+ * `!isSafe` (`navigator.js:71-88`). Other URLs in the cache may have
626
+ * been server-side-mutated by this submission; refusing to clear would
627
+ * serve stale content on subsequent back/forward.
628
+ *
629
+ * Submission-state events + aria-busy: while the enhanced submission fetch
630
+ * is in flight the router sets `aria-busy="true"` on the FORM element and
631
+ * dispatches `webjs:submit-start` (detail `{ form, url }`); on EVERY settle
632
+ * path (success swap, validation re-render, navigation error, abort by a
633
+ * superseding submit/nav) it clears `aria-busy` and dispatches
634
+ * `webjs:submit-end` (detail `{ form, url, ok }`, `ok` = the submission was
635
+ * not an error outcome). The toggle uses the same nav-token guard the
636
+ * `<webjs-frame>` busy state uses (`formBusyTokens` / `markFormBusy` /
637
+ * `clearFormBusy`): a superseded submit's teardown never clears the busy
638
+ * state a NEWER submit already set, so a rapid re-submit stays busy until the
639
+ * live submission settles. The native `aria-busy` attribute on the form is
640
+ * the readable "is this form submitting" primitive (any component can read
641
+ * it); the events are the push-notification counterpart. Progressive
642
+ * enhancement: with JS off this whole code path is skipped and the form is a
643
+ * plain POST.
644
+ *
645
+ * @param {string} href Absolute target URL.
646
+ * @param {string} method Lowercased HTTP verb.
647
+ * @param {FormData | URLSearchParams} body Encoded per the declared enctype
648
+ * (#1307): `FormData` for multipart, `URLSearchParams` for urlencoded. Both
649
+ * iterate as `[name, value]` pairs, which is all the safe-method query-string
650
+ * promotion below needs, and `fetch` derives the right content type from
651
+ * either without an explicit header.
652
+ * @param {string | null} frameId
653
+ * @param {HTMLFormElement | null} [form] The submitted form, for busy + events.
654
+ */
655
+ export async function performSubmission(href, method, body, frameId, form) {
656
+ if (activeAbortController) activeAbortController.abort();
657
+ activeAbortController = new AbortController();
658
+ const signal = activeAbortController.signal;
659
+ const myToken = bumpNavToken();
660
+ // Same reasoning as performNavigation: a submission is a navigation, so it
661
+ // ends any restore window a recent Back left open (#1310), and cancels a
662
+ // clamped restore's catch-up. Frame-targeted submissions are excluded on the
663
+ // same reasoning as the frame navs above.
664
+ if (!frameId) {
665
+ bumpRestoreGeneration();
666
+ if (releaseScrollAnchor) releaseScrollAnchor();
667
+ if (cancelScrollCatchUp) cancelScrollCatchUp();
668
+ }
669
+
670
+ const isSafe = method === 'get' || method === 'head';
671
+ let url = new URL(href, location.href);
672
+ if (isSafe) {
673
+ // Promote body to query string per the HTML5 form-submission
674
+ // algorithm. The form's own `action` query is replaced: same as
675
+ // a native GET-form submission.
676
+ url.search = '';
677
+ for (const [k, v] of body) {
678
+ url.searchParams.append(k, typeof v === 'string' ? v : v.name);
679
+ }
680
+ }
681
+
682
+ // Snapshot the page being submitted from (form submissions are
683
+ // always foreground / never popstate, so `currentPageUrl` already
684
+ // matches `location.href`: but use the tracker for consistency
685
+ // with performNavigation).
686
+ if (currentPageUrl) snapshotCurrent(currentPageUrl);
687
+
688
+ let navigatingFlagTimer = setTimeout(() => {
689
+ setNavigating(true);
690
+ navigatingFlagTimer = null;
691
+ }, 150);
692
+
693
+ const optimisticState = applyOptimisticLoading();
694
+
695
+ // Submission-state lifecycle: mark the form busy + announce the start, then
696
+ // clear + announce the settle in the finally so EVERY exit (success,
697
+ // validation re-render, navigation error, abort by a superseding submit)
698
+ // balances the pair. `ok` is filled from the fetch outcome; an abort or a
699
+ // teardown that never reached the fetch settles ok:false. The token guard
700
+ // (markFormBusy/clearFormBusy) keeps a superseded submit's teardown from
701
+ // clearing the busy state a newer submit set.
702
+ const busyForm = form ? markFormBusy(form, myToken, url.href) : null;
703
+ let outcomeOk = false;
704
+ try {
705
+ const outcome = await fetchAndApply(
706
+ url.href,
707
+ frameId,
708
+ /* recordHistory */ true,
709
+ optimisticState,
710
+ isSafe ? 'GET' : method.toUpperCase(),
711
+ isSafe ? null : body,
712
+ signal,
713
+ myToken,
714
+ );
715
+ outcomeOk = !!(outcome && outcome.ok);
716
+ // Mutating submissions invalidate cached versions of other URLs -
717
+ // do this *after* the response applies so the new page itself is
718
+ // snapshotted on the next nav, not pre-emptively wiped. Clear the
719
+ // speculative prefetch cache too: a fragment prefetched before this
720
+ // mutation would otherwise be served stale on a later forward click.
721
+ if (!isSafe && myToken === currentNavigationToken) {
722
+ snapshotCache.clear();
723
+ prefetchClearAll();
724
+ }
725
+ } finally {
726
+ if (busyForm) clearFormBusy(busyForm, myToken, url.href, outcomeOk);
727
+ if (navigatingFlagTimer) clearTimeout(navigatingFlagTimer);
728
+ if (myToken === currentNavigationToken) {
729
+ setNavigating(false);
730
+ if (typeof location !== 'undefined') currentPageUrl = location.href;
731
+ }
732
+ }
733
+ }
734
+
735
+
736
+ /* ====================================================================
737
+ * Link prefetch (Remix-style strategies, fast-by-default)
738
+ *
739
+ * A link click already resolves through fetchAndApply, but the fetch
740
+ * only STARTS on click, so the user waits a full round-trip. Prefetch
741
+ * warms a dedicated cache speculatively so the click reads it instantly.
742
+ *
743
+ * Strategy per anchor via a `data-prefetch` attribute (valid-HTML data-*,
744
+ * like SvelteKit / Astro). The default is DEVICE-ADAPTIVE so the common case
745
+ * is fast on every device without per-link opt-in: `intent` on a hover-capable
746
+ * pointer (a real head-start before the click), `viewport` on touch (no hover
747
+ * exists, and `touchstart` fires too close to the tap to front-run it). Value
748
+ * vocabulary borrows Next's true/false/auto aliases:
749
+ * - absent (default) : intent on pointer, viewport on touch (adaptive)
750
+ * - intent : hover / focus / touch, after a short dwell
751
+ * - true / render : eager, as soon as a document scan sees it
752
+ * - auto / viewport : on viewport entry (IntersectionObserver, 0.5),
753
+ * after a dwell so a fast scroll-through skips it
754
+ * - false / none : never (also data-no-prefetch / rel="external")
755
+ *
756
+ * Why a separate cache, not snapshotCache: snapshotCache is keyed to the
757
+ * back/forward restore path (popstate), which holds the FULL serialized
758
+ * document of pages the user already visited. A prefetch holds the
759
+ * SERVER FRAGMENT for a page not yet visited (the same X-Webjs-Have
760
+ * partial body a real nav would receive). fetchAndApply consumes it via
761
+ * prefetchTake() before falling back to the network.
762
+ *
763
+ * Only same-origin in-app links are prefetched (the same eligibility as
764
+ * a click), and never under Save-Data / prefers-reduced-data / a 2g link,
765
+ * never past a small concurrency cap, and never twice (deduped + cached). The
766
+ * viewport path additionally waits a dwell and cancels on scroll-out, so a
767
+ * fast scroll through a long list does not flood the network tab. There is no
768
+ * logout-style heuristic: prefetch issues a real GET, so as everywhere in
769
+ * the ecosystem (Next / Nuxt / Remix), a non-idempotent action must be a
770
+ * POST or a `<form>`, and `data-no-prefetch` / `rel="external"` opt out.
771
+ *
772
+ * What we do NOT touch: a native `<link rel="prefetch">` in the document
773
+ * head is the browser's own mechanism and warms the HTTP cache; we never
774
+ * interfere with it.
775
+ * ==================================================================== */
776
+
777
+ /** Test-only: read the "current page URL" tracker (used for snapshot keying). */
778
+ export function _currentPageUrl() { return currentPageUrl; }
779
+
780
+ /** Test-only: set the tracker (simulates being on a specific page). */
781
+ export function _setCurrentPageUrl(u) { currentPageUrl = u; }