@webjsdev/core 0.7.50 → 0.7.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/webjs-core-browser.js +5 -5
  2. package/dist/webjs-core-browser.js.map +3 -3
  3. package/dist/webjs-core-testing.js +20 -20
  4. package/dist/webjs-core-testing.js.map +3 -3
  5. package/dist/webjs-core.js +7 -7
  6. package/dist/webjs-core.js.map +3 -3
  7. package/index-browser.js +1 -1
  8. package/index.d.ts +3 -3
  9. package/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/component/lifecycle.js +1481 -0
  12. package/src/component/reactive.js +171 -0
  13. package/src/component/server-element.js +248 -0
  14. package/src/component.js +2 -1896
  15. package/src/css.d.ts +9 -0
  16. package/src/escape.d.ts +2 -0
  17. package/src/html.d.ts +9 -0
  18. package/src/render-client/parts.js +2039 -0
  19. package/src/render-client/reconciler.js +388 -0
  20. package/src/render-client/template-compiler.js +525 -0
  21. package/src/render-client.js +1 -2938
  22. package/src/render-server/attrs.js +160 -0
  23. package/src/render-server/dsd.js +445 -0
  24. package/src/render-server/html-scan.js +402 -0
  25. package/src/render-server/slots.js +213 -0
  26. package/src/render-server/stream.js +163 -0
  27. package/src/render-server/template-renderer.js +938 -0
  28. package/src/render-server/text.js +163 -0
  29. package/src/render-server.js +6 -2405
  30. package/src/repeat.d.ts +15 -0
  31. package/src/rich-fetch.d.ts +4 -0
  32. package/src/router-client/anchors.js +44 -0
  33. package/src/router-client/boundaries.js +221 -0
  34. package/src/router-client/constants.js +206 -0
  35. package/src/router-client/diagnostics.js +377 -0
  36. package/src/router-client/dom-differ.js +709 -0
  37. package/src/router-client/dom-parse.js +150 -0
  38. package/src/router-client/events.js +182 -0
  39. package/src/router-client/fetch-apply.js +432 -0
  40. package/src/router-client/form-encoder.js +135 -0
  41. package/src/router-client/frames.js +329 -0
  42. package/src/router-client/head-merge.js +344 -0
  43. package/src/router-client/nav-error.js +105 -0
  44. package/src/router-client/navigator.js +952 -0
  45. package/src/router-client/prefetch.js +831 -0
  46. package/src/router-client/scroll.js +308 -0
  47. package/src/router-client/snapshot-cache.js +113 -0
  48. package/src/router-client/state.js +155 -0
  49. package/src/router-client/stream.js +206 -0
  50. package/src/router-client/swap.js +454 -0
  51. package/src/router-client/upgrade.js +261 -0
  52. package/src/router-client/view-transition.js +223 -0
  53. package/src/router-client.d.ts +11 -2
  54. package/src/router-client.js +78 -5345
  55. package/src/slot/assignment.js +304 -0
  56. package/src/slot/interception.js +558 -0
  57. package/src/slot/polyfills.js +365 -0
  58. package/src/slot/project.js +432 -0
  59. package/src/slot/sensors.js +303 -0
  60. package/src/slot/state.js +245 -0
  61. package/src/slot/symbols.js +85 -0
  62. package/src/slot.js +7 -2199
  63. package/src/suspense.d.ts +9 -0
  64. package/src/websocket-client.d.ts +22 -0
@@ -0,0 +1,952 @@
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, releaseHeightReservation, releaseScrollAnchor, reserveRestoredHeight, restoreGeneration, suppressScrollAnchoring } from './scroll.js';
21
+ import { snapshotCache, snapshotCurrent, snapshotGet } from './snapshot-cache.js';
22
+ import { _setEnabled, bumpNavToken, clearFragmentNav, consumeFragmentNav, 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
+ * Absorb the popstate produced by a fragment click the router bowed out of.
55
+ *
56
+ * The spec's "navigate to a fragment" ends by firing popstate, so an ordinary
57
+ * `<a href="#section">` click reaches `onPopState`, which treated every
58
+ * popstate as back/forward and re-navigated. That re-fetched the page and
59
+ * re-swapped the DOM, destroying live node identity and hydrated state outside
60
+ * the anchor and undoing the jump the reader had just asked for (#1437).
61
+ *
62
+ * The gate is PROVENANCE, and it is the only thing that works. The router SAW
63
+ * the click it bowed out of, and it never sees a traversal, so `onClick` leaves
64
+ * a mark and the next popstate consumes it (`state.js` documents the lifetime).
65
+ * Two earlier attempts tried to decide this from the urls instead and both were
66
+ * measured wrong, because the urls carry no signal that separates the cases:
67
+ *
68
+ * - a REPEAT click REPLACES its entry rather than pushing, so it arrives with
69
+ * `location.href` UNCHANGED, exactly like a Back between two distinct
70
+ * entries that share a url. (Two clicks of one `#sec` link give two
71
+ * popstates at the same href, `history.length` unchanged, measured in
72
+ * Chromium.) So an unchanged url cannot mean "absorb".
73
+ * - a CHANGED url cannot mean "absorb" either. Same pathname and search does
74
+ * prove the two entries resolve to the same server response, but not that
75
+ * they hold the same DOM, and a swap in between makes them differ. The
76
+ * no-JS write path reaches that shape: `getSubmitAction` prefers the raw
77
+ * `action` ATTRIBUTE (`form-encoder.js:33`), which carries no fragment, so
78
+ * a bound-submitter form declaring `action="/p"` pushes its 422 re-render
79
+ * at `/p` while the reader sits at `/p#sec`. Back from that validation
80
+ * error differs only by fragment and still has to re-render.
81
+ *
82
+ * So a popstate the router did not cause is left alone, whatever its url.
83
+ *
84
+ * What that does NOT cover, deliberately: an ordinary Back or Forward between
85
+ * two fragment states of one page, which is a traversal with no click behind
86
+ * it, so it still re-navigates as it does today. Telling that apart from the
87
+ * 422 case needs to know whether the DOM was replaced between the two ENTRIES,
88
+ * which is per-entry state the router does not keep (`history.pushState` is
89
+ * called with `null` throughout). Turbo tags its entries for exactly this
90
+ * reason. Adding that here is a larger change than this fix, and getting it
91
+ * wrong reintroduces a swallowed Back, so this stops at the click.
92
+ *
93
+ * It RECORDS as well as deciding, so `currentPageUrl` tracks the fragment the
94
+ * reader is on. That field is otherwise written only in the `finally` of a
95
+ * completed navigation, and `snapshotCurrent` keys through `cacheKey`, which
96
+ * strips the fragment, so the record cannot disturb the snapshot cache.
97
+ *
98
+ * @param {string} href The destination, i.e. `location.href` at popstate time.
99
+ * @returns {boolean} True when the popstate was absorbed and the caller must do
100
+ * nothing further.
101
+ */
102
+ export function absorbFragmentClickPopState(href) {
103
+ // Consume unconditionally, so a mark can never outlive the popstate it was
104
+ // left for, whatever this one turns out to be.
105
+ if (!consumeFragmentNav(href)) return false;
106
+ if (!currentPageUrl) return false;
107
+ /** @type {URL} */ let prev;
108
+ /** @type {URL} */ let next;
109
+ try {
110
+ prev = new URL(currentPageUrl);
111
+ next = new URL(href, location.href);
112
+ } catch {
113
+ return false;
114
+ }
115
+ // Defense in depth, and UNREACHABLE by construction today, so it carries no
116
+ // counterfactual: `onClick` marks only when the anchor's pathname and search
117
+ // already match `location`, and every writer of `currentPageUrl` clears the
118
+ // mark before it writes (`performNavigation`, `performSubmission`) or nulls
119
+ // it (`disableClientRouter`, with enable re-seeding). It is kept because that
120
+ // invariant lives in three separate files: a fourth writer of
121
+ // `currentPageUrl` added without clearing the mark would make it reachable,
122
+ // and this is what keeps that mistake from absorbing a popstate on a page the
123
+ // mark was never left for. If you add such a writer, clear the mark there.
124
+ if (prev.pathname !== next.pathname || prev.search !== next.search) return false;
125
+ currentPageUrl = next.href;
126
+ return true;
127
+ }
128
+
129
+ /**
130
+ * The app's own `history.scrollRestoration`, captured at enable so
131
+ * `disableClientRouter()` can put it back.
132
+ *
133
+ * The router writes 'auto' because the restore is the BROWSER's now (#1428)
134
+ * and 'manual' would mean no restore at all. That write is an override, so it
135
+ * owes the app its value back when the router steps aside.
136
+ *
137
+ * @type {ScrollRestoration | null}
138
+ */
139
+ let prevScrollRestoration = null;
140
+
141
+ /** Enable the client router. Idempotent. */
142
+ export function enableClientRouter() {
143
+ if (enabled || typeof document === 'undefined') return;
144
+ _setEnabled(true);
145
+ // Publish the in-place refresh entry on the global (#1398). The dev
146
+ // live-reload client is a separate served script with no import of this
147
+ // module, so a global is the only seam it has, and its PRESENCE is the
148
+ // feature detection: an app that opted out with `webjs.clientRouter: false`
149
+ // never calls this, and a page that ships no component never loads
150
+ // @webjsdev/core at all, so both no-router cases resolve to a full reload
151
+ // without either side assuming anything about the other.
152
+ /** @type any */ (globalThis).__webjsRefreshPage = refreshPage;
153
+ // Both `click` and `submit` are BUBBLE phase, not capture. A component's
154
+ // per-element `@click` / `@submit` handler (render-client.js) runs in the
155
+ // at-target phase, BEFORE a document-level bubble listener. So onClick /
156
+ // onSubmit run AFTER the component, and their `if (e.defaultPrevented) return`
157
+ // guard sees the component's `preventDefault` and leaves the element alone.
158
+ // A capture listener would run FIRST, before the component, so the guard
159
+ // would always see `false` and the router would wrongly hijack a JS-handled
160
+ // link or form: navigate a `<a @click=${e => e.preventDefault()}>` away, or
161
+ // submit a `<form @submit=${e => e.preventDefault()}>` (the live chat /
162
+ // comments forms, which preventDefault and send over WebSocket / fetch),
163
+ // navigating the page out from under it. All the phase-independent filtering
164
+ // (modifier / middle clicks, downloads, cross-origin, hash links, GET-vs-POST)
165
+ // happens inside onClick / onSubmit regardless of phase. Mirrors
166
+ // hotwired/turbo, which does its interception work in bubble listeners.
167
+ document.addEventListener('click', onClick, false);
168
+ document.addEventListener('submit', onSubmit, false);
169
+ window.addEventListener('popstate', onPopState);
170
+ // Intent prefetch: warm the next page on hover / focus / touch-start.
171
+ // pointerover + focusin bubble, so one delegated listener each covers
172
+ // the whole document, including links added by later navigations.
173
+ document.addEventListener('pointerover', onPrefetchIntent, true);
174
+ document.addEventListener('focusin', onPrefetchIntent, true);
175
+ document.addEventListener('touchstart', onPrefetchIntent, { capture: true, passive: true });
176
+ document.addEventListener('pointerout', onPrefetchOut, true);
177
+ // After every client navigation the swapped-in DOM may carry new
178
+ // anchors, so re-scan for render/viewport modes. webjs:navigate fires
179
+ // at the end of fetchAndApply for both link and frame swaps.
180
+ document.addEventListener('webjs:navigate', refreshPrefetchObservers);
181
+ ensureUpgradeObserver();
182
+ // Apply render/viewport prefetch modes to the initial document.
183
+ refreshPrefetchObservers();
184
+ // Scroll restoration is the BROWSER's, and this states that rather than
185
+ // assuming it. Under 'auto' the browser records a scroll position per
186
+ // history entry, replays it on a traverse, and WebKit composes the iOS
187
+ // back-swipe gesture preview from that same recorded state. Under 'manual'
188
+ // it records nothing, so the preview renders BLANK for the whole gesture on
189
+ // any scrolled page (#1428, measured on-device: WebJs and Turbo Drive both
190
+ // took 'manual' and both blank; Next leaves 'auto' and is clean).
191
+ //
192
+ // Written EXPLICITLY, not left to the default, because the restore now has
193
+ // no writer of its own. The router reserves the recorded height and lets the
194
+ // UA replay the offset, so an app that had set 'manual' (the first line of
195
+ // most ported scroll-restoration recipes, and what this router itself did
196
+ // until #1428) would get NO Back restore at all: the UA replays nothing, the
197
+ // reservation prevents the clamp that would otherwise fire a `scroll` event,
198
+ // and the window's write-back is scroll-event-driven so it never runs. The
199
+ // reader would simply stay at the outgoing page's offset. Prose in the docs
200
+ // cannot prevent that; this line can.
201
+ //
202
+ // Per-entry, not global, per the HTML spec: this sets the mode on the
203
+ // current entry, and an entry created by a later `pushState` inherits it,
204
+ // which is exactly the reach the router needs.
205
+ //
206
+ // Saved and put back on `disableClientRouter()`, the same contract the
207
+ // anchoring window and the height reservation keep for the inline styles
208
+ // they touch. Without it the documented runtime opt-out would silently
209
+ // strand an app that had its own restoration on 'manual', leaving it
210
+ // double-restoring with no way to detect why.
211
+ if (typeof history !== 'undefined' && 'scrollRestoration' in history) {
212
+ prevScrollRestoration = history.scrollRestoration;
213
+ history.scrollRestoration = 'auto';
214
+ }
215
+ // Seed the "current page" tracker so the first navigation can
216
+ // snapshot the page the user is leaving.
217
+ if (typeof location !== 'undefined') currentPageUrl = location.href;
218
+ // Last, once the listeners are on: report whether the load that got us here
219
+ // was a same-origin navigation the router never saw (#1118). Running it after
220
+ // the listeners means a throw inside a diagnostic can never leave the router
221
+ // half-installed.
222
+ reportPreBootNavigation();
223
+ }
224
+
225
+ /** Disable the client router. */
226
+ export function disableClientRouter() {
227
+ if (!enabled) return;
228
+ _setEnabled(false);
229
+ document.removeEventListener('click', onClick, false);
230
+ document.removeEventListener('submit', onSubmit, false);
231
+ window.removeEventListener('popstate', onPopState);
232
+ // A mark left by a click whose popstate has not fired yet must not survive
233
+ // the router stepping aside, or it would absorb the first same-url popstate
234
+ // after a re-enable, which is the swallowed-Back failure this exists to
235
+ // prevent (#1437). Every other pending piece of router state is torn down
236
+ // here too.
237
+ clearFragmentNav();
238
+ document.removeEventListener('pointerover', onPrefetchIntent, true);
239
+ document.removeEventListener('focusin', onPrefetchIntent, true);
240
+ document.removeEventListener('touchstart', onPrefetchIntent, /** @type any */ ({ capture: true }));
241
+ document.removeEventListener('pointerout', onPrefetchOut, true);
242
+ document.removeEventListener('webjs:navigate', refreshPrefetchObservers);
243
+ clearPrefetchHover();
244
+ clearPrefetchViewTimers();
245
+ teardownPrefetchViewObserver();
246
+ // Give the app back the scroll-restoration mode the router overrode.
247
+ if (typeof history !== 'undefined' && prevScrollRestoration !== null) {
248
+ history.scrollRestoration = prevScrollRestoration;
249
+ prevScrollRestoration = null;
250
+ }
251
+ // Never leave a restore window open on <html>, nor a height reservation
252
+ // held on it, after the router is gone (#1310 / #1428).
253
+ bumpRestoreGeneration();
254
+ if (releaseScrollAnchor) releaseScrollAnchor();
255
+ if (releaseHeightReservation) releaseHeightReservation();
256
+ currentPageUrl = null;
257
+ // Unpublish the refresh entry (#1398) so the dev reload client's feature
258
+ // detection sees the router is gone and falls back to a full reload.
259
+ delete (/** @type any */ (globalThis).__webjsRefreshPage);
260
+ }
261
+
262
+ /**
263
+ * Programmatic navigation (replaces `location.href = url`).
264
+ *
265
+ * `scroll: false` is the programmatic twin of `data-preserve-scroll` on a link
266
+ * (#1436), spelled the way Next spells it on its own programmatic entry
267
+ * (`router.push(url, { scroll: false })`), which is the reflex a reader arrives
268
+ * with. Default is `true`, so an omitted option scrolls to top exactly as
269
+ * before.
270
+ *
271
+ * @param {string} url
272
+ * @param {{ replace?: boolean, scroll?: boolean }} [opts]
273
+ */
274
+ export async function navigate(url, opts) {
275
+ const target = new URL(url, location.href);
276
+ if (target.origin !== location.origin) {
277
+ // Cross-origin: an intentional full-page nav, not a degradation, but it
278
+ // ends the session in a test just the same, so it rides the same seam.
279
+ hardNavigate(url);
280
+ return;
281
+ }
282
+ await performNavigation(target.href, opts?.replace ?? false, null, {
283
+ preserveScroll: opts?.scroll === false,
284
+ });
285
+ }
286
+
287
+ /**
288
+ * Self-load a `<webjs-frame src>`: fetch `url` as a frame nav and apply the
289
+ * matching `<webjs-frame id>` subtree into `frameEl` through the EXACT same
290
+ * frame-swap path a click-driven frame nav uses (`fetchAndApply` with the
291
+ * frame's id). So the #252 `aria-busy` lifecycle + `webjs:frame-busy` events,
292
+ * the #249 `webjs:navigation-error` recovery, the keyed reconciler, and the
293
+ * `webjs:frame-missing` fallback all apply for free; a `src` self-load and a
294
+ * click that targets the same frame produce identical DOM.
295
+ *
296
+ * This is NOT a page navigation: it records no history entry, takes no page
297
+ * snapshot, and shows no optimistic loading skeleton (it swaps one region, not
298
+ * the page). It also stands entirely outside the speculative prefetch cache
299
+ * (#1407): it EVICTS the warm entry for this url in this frame's dimension and
300
+ * passes `noPrefetch` so it cannot consume one, because a `src` self-load or
301
+ * `src` mutation is the app asking for THIS frame's content now, a freshness
302
+ * request rather than a hover being followed. Refusing without evicting would
303
+ * leave a copy this load is about to supersede, so a later click on that
304
+ * frame's link would repaint it with staler bytes than are already on screen.
305
+ * The evict half runs only once the swap has COMMITTED, so a load that aborts,
306
+ * fails, or hits `webjs:frame-missing` leaves the warm entry alone: the frame
307
+ * still shows its old content, which that entry still matches.
308
+ *
309
+ * It runs under a fresh nav token + AbortController so it interleaves
310
+ * safely with real navigations and with a superseding `src` change on the same
311
+ * frame (the later load's token wins; the earlier one's teardown never clears
312
+ * the newer load's busy state, see `frameBusyTokens`).
313
+ *
314
+ * Called only by `<webjs-frame>` itself (`webjs-frame.js`), which owns the
315
+ * no-double-load guard (eager connect vs lazy-viewport vs a `src` mutation).
316
+ *
317
+ * @param {Element} frameEl The live `<webjs-frame>` element to fill.
318
+ * @param {string} url The `src` value, resolved against `location.href`.
319
+ * @returns {Promise<{ ok: boolean, status: number | null, aborted: boolean, applied: boolean }>}
320
+ * Passed straight through from `fetchAndApply`, so `applied` says whether the
321
+ * frame subtree was actually swapped (#1398). Each guard below returns it
322
+ * explicitly rather than leaving the field off: one path omitting it would be
323
+ * the same kind of contract hole the flag exists to close.
324
+ */
325
+ export async function loadFrame(frameEl, url) {
326
+ if (typeof location === 'undefined') return { ok: false, status: null, aborted: false, applied: false };
327
+ const id = frameEl && /** @type any */ (frameEl).id;
328
+ if (!id) return { ok: false, status: null, aborted: false, applied: false };
329
+ const target = new URL(url, location.href);
330
+ // Cross-origin can't be a same-document frame swap (and a frame fetch must
331
+ // send a same-origin credentialed request). Leave the frame unchanged.
332
+ if (target.origin !== location.origin) return { ok: false, status: null, aborted: false, applied: false };
333
+
334
+ // A frame self-load shares the global abort + token machinery so a real
335
+ // navigation that starts mid-load supersedes it (and vice versa), exactly
336
+ // like a click-driven frame nav routed through performNavigation.
337
+ if (activeAbortController) activeAbortController.abort();
338
+ activeAbortController = new AbortController();
339
+ const signal = activeAbortController.signal;
340
+ const myToken = bumpNavToken();
341
+
342
+ const outcome = await fetchAndApply(
343
+ target.href,
344
+ id,
345
+ /* recordHistory */ false,
346
+ /* optimisticState */ null,
347
+ 'GET',
348
+ /* body */ null,
349
+ signal,
350
+ myToken,
351
+ /* revalidating */ false,
352
+ // A self-load never consumes a speculative entry (#1407). Dropping the
353
+ // blanket `!frameId` guard from the consume check made this path eligible
354
+ // for the first time, and it should not be: a `src` self-load or a `src`
355
+ // mutation is the app asking for THIS frame's content now, which is a
356
+ // freshness request, not the click-follows-hover shape the warm cache
357
+ // exists to serve. Deliberately out of scope for that change.
358
+ { noPrefetch: true },
359
+ );
360
+
361
+ // A self-load is a freshness request, so it does BOTH halves (#1407): it
362
+ // declines to consume a warm entry (above), and it drops the copy it has
363
+ // superseded. Only once the swap actually COMMITTED, though, which `applied`
364
+ // is exactly the question for (`ok` is not, since a 422 or a notFound() page
365
+ // still commits). The clearest case is a `webjs:frame-missing` or an abort by
366
+ // a newer nav: the frame keeps showing its old content, which the warm entry
367
+ // still matches, so evicting would cost the next click a round trip for
368
+ // nothing. A transport failure or a non-HTML body do NOT leave the page
369
+ // untouched (`handleNavigationError` either swaps an error alert into the
370
+ // deepest layout range or hard-navigates), but they commit nothing for this
371
+ // FRAME either, so the same gate is right for them by the same rule.
372
+ if (outcome && outcome.applied) prefetchEvict(target.href, id);
373
+ return outcome;
374
+ }
375
+
376
+ /**
377
+ * Invalidate a cached snapshot. Call after a server action mutates data
378
+ * that affects a cached page so the next visit refetches.
379
+ *
380
+ * Evicts BOTH the back/forward snapshot cache and the speculative
381
+ * prefetch cache. A prefetched fragment captured before a mutation would
382
+ * otherwise be served stale on the next forward click, the same staleness
383
+ * the snapshot eviction prevents for back/forward.
384
+ *
385
+ * @param {string} [url] Specific URL to invalidate, or omit to clear all.
386
+ */
387
+ export function revalidate(url) {
388
+ // Falsy `url` (undefined, null, empty string) clears everything.
389
+ // Loose `== null` would have left `revalidate('')` to silently no-op,
390
+ // because `new URL('', location.href)` is a valid relative URL and the
391
+ // resulting cache key rarely matches anything.
392
+ // Neither form touches the refusal memos (#1407). This is the post-MUTATION
393
+ // api an app calls after an RPC write, so clearing here would drop every memo
394
+ // on every mutation and reopen the request-per-hover loop the memo exists to
395
+ // close. A mutation CAN change a render's streamed shape, since a page may
396
+ // render `Suspense` conditionally on fetched data, but a memo that outlives
397
+ // that costs one skipped warm-up for that key until the TTL, which is the
398
+ // cheaper side of the trade. `refreshPage`, where the SOURCE may have changed,
399
+ // clears them itself.
400
+ if (!url) { snapshotCache.clear(); prefetchClearAll(); return; }
401
+ const u = new URL(url, location.href);
402
+ const key = u.pathname + u.search;
403
+ snapshotCache.delete(key);
404
+ // EVERY dimension of that url, not just the page one (#1407). A frame link's
405
+ // entry is keyed `<frameId> <path>`, so deleting the bare path would leave a
406
+ // pre-mutation frame subtree consumable by the next frame click for the rest
407
+ // of its TTL, which is the exact staleness this function exists to prevent.
408
+ // The path half can never contain a space (the URL parser percent-encodes
409
+ // one), so a trailing-space match identifies the framed keys unambiguously.
410
+ prefetchCache.delete(key);
411
+ const framedSuffix = ` ${key}`;
412
+ for (const k of [...prefetchCache.keys()]) {
413
+ if (k.endsWith(framedSuffix)) prefetchCache.delete(k);
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Re-render the CURRENT url on the server and apply it in place, with no page
419
+ * reload (#1398).
420
+ *
421
+ * Dev-facing today, since the live-reload client calls it for a page or layout
422
+ * edit, but it is a plain capability with no dev-only code in it.
423
+ *
424
+ * It records no history entry and never scrolls, so the reader keeps their
425
+ * place and Back still goes to the previous page. `mode` picks the swap tier:
426
+ *
427
+ * - `'page'` morphs the deepest shared boundary, so the outer layout's DOM and
428
+ * the hydrated state of its components survive. This is the light tier and
429
+ * the default.
430
+ * - `'shell'` replaces the whole body. Needed when the LAYOUT's own markup
431
+ * changed, because that markup lives outside every children range and a
432
+ * boundary morph would leave it untouched. Component instances do not survive
433
+ * it.
434
+ *
435
+ * It does NOT reload changed component modules, and cannot: `customElements
436
+ * .define` is once-per-tag and a module URL is fetched once per document. A
437
+ * caller whose change touched browser code must reload instead.
438
+ *
439
+ * @param {'page' | 'shell'} [mode]
440
+ * @returns {Promise<boolean>} whether the refresh applied. `false` means the
441
+ * caller should fall back to a full load.
442
+ */
443
+ export async function refreshPage(mode) {
444
+ if (!enabled || typeof location === 'undefined') return false;
445
+ // Every cached copy predates the change, so drop both caches before fetching.
446
+ revalidate();
447
+ // And the refusal memos (#1407), which `revalidate` deliberately leaves alone
448
+ // because it is also the post-mutation api. Here the SOURCE changed, and an
449
+ // edit can start or stop a route streaming, which is what produced the
450
+ // unmarked answer a memo recorded. What reaches here is an edit to a file the
451
+ // browser does NOT download: a page gaining or losing a `Suspense(...)`
452
+ // boundary is the plain case, since rendering one does not by itself make the
453
+ // page client-effecting, so it classifies `page` rather than a reload. An edit
454
+ // to anything that SHIPS reloads instead and never arrives, which covers a
455
+ // `loading.{js,ts}` either direction (it can never be elided, so it is always
456
+ // shipped, and a brand-new one is in no graph at all).
457
+ clearPrefetchRefused();
458
+ try {
459
+ const res = await performNavigation(location.href, false, null, { refresh: mode === 'shell' ? 'shell' : 'page' });
460
+ // The outcome has to be READ, not inferred from the absence of a throw.
461
+ // `fetchAndApply` reports every real failure as `{ applied: false }` and
462
+ // throws for none of them (a rejected fetch, a non-HTML body, an
463
+ // unparseable one), so a bare try/catch here would resolve `true` for
464
+ // exactly the cases the caller's full-load fallback exists to cover, and
465
+ // the page would silently sit on stale content.
466
+ //
467
+ // It reads `applied`, NOT `ok`. An HTML body of any status is swapped in
468
+ // place, so a page rendered through `notFound()`, `forbidden()`, or an
469
+ // `error.ts` boundary is `ok: false` and applied perfectly well. Reading
470
+ // `ok` would report those as failures and make the dev client reload on top
471
+ // of a swap that already happened, losing the state this exists to keep,
472
+ // every time you iterate on a page that currently renders a 4xx or 5xx.
473
+ //
474
+ // Two outcomes are NOT failures either. A `null` means no fetch decided it
475
+ // (the parse guard hard-navigated, or a popstate restored a snapshot), so
476
+ // the page is already resolved. An `aborted` means a newer navigation
477
+ // superseded this one and owns the page now, and reloading would yank the
478
+ // reader out of it; Turbo drops a refresh that lands mid-navigation for the
479
+ // same reason.
480
+ if (!res || res.aborted) return true;
481
+ return res.applied;
482
+ } catch (_) {
483
+ return false;
484
+ }
485
+ }
486
+
487
+ // Auto-enable on import: deferred to the END of this module (see the
488
+ // call after the test-only exports). enableClientRouter() transitively
489
+ // reads the prefetch state (prefetchViewObserver and the caches), which
490
+ // are `const`/`let` declared lower in the file and therefore in the
491
+ // temporal dead zone here. Calling enable at module-end, after every
492
+ // top-level binding is initialised, avoids a ReferenceError in the
493
+ // bundled browser build.
494
+
495
+ /* ====================================================================
496
+ * Click + popstate handlers
497
+ * ==================================================================== */
498
+
499
+ /**
500
+ * @param {string} href
501
+ * @param {boolean} isPopState
502
+ * @param {string | null} frameId Active <webjs-frame> id, or null.
503
+ * @param {{ refresh?: 'page' | 'shell', preserveScroll?: boolean }} [opts]
504
+ * `refresh` marks a same-URL in-place re-render (#1398). It suppresses three
505
+ * things a forward navigation does and a refresh must not: the outgoing
506
+ * snapshot, the optimistic loading skeleton, and history plus scroll. See
507
+ * `refreshPage`. `preserveScroll` keeps the reader's current offset on a
508
+ * forward navigation that WOULD otherwise scroll to top (#1436), from
509
+ * `data-preserve-scroll` or `navigate(url, { scroll: false })`. The two are
510
+ * independent: a refresh already writes no scroll, so `preserveScroll` adds
511
+ * nothing there.
512
+ * @returns {Promise<{ ok: boolean, status: number | null, aborted: boolean, applied: boolean } | null>}
513
+ * The `fetchAndApply` outcome, so a caller can tell an applied navigation from
514
+ * a failed one (#1398). Read `applied` rather than `ok` for that question: an
515
+ * HTML body of any status is swapped in place, so a rendered 404 or 500 is
516
+ * `ok: false` and `applied: true`. `null` means no fetch decided the outcome:
517
+ * the parse guard hard-navigated, or a popstate restored from the snapshot
518
+ * cache. Both already resolved the page, so a caller must not treat `null` as
519
+ * a failure. Every other caller ignores the value.
520
+ */
521
+ export async function performNavigation(href, isPopState, frameId, opts) {
522
+ // Same reasoning as in performSubmission: a real navigation invalidates any
523
+ // pending fragment mark (#1437).
524
+ clearFragmentNav();
525
+ const refresh = (opts && opts.refresh) || undefined;
526
+ const preserveScroll = !!(opts && opts.preserveScroll);
527
+ // #1008 / #936: a forward, main-document nav fired while the document is
528
+ // still parsing (`readyState === 'loading'`) races the DOM. The leaving
529
+ // page's closing layout markers at the bottom of the body may not exist yet,
530
+ // so `snapshotCurrent` plus region discovery would capture an incomplete tree
531
+ // and drive a corrupt or over-wide swap (the suspected root cause of the
532
+ // dropped-marker reports). The PREFETCH path already skips this window (see
533
+ // the `buildHaveHeader` call site); the click / `navigate()` path did not.
534
+ // Degrade to a correct full-page load, which is what an MPA would do anyway.
535
+ // Scoped to frameless forward navs: popstate is browser-driven, and a frame
536
+ // nav carries its own boundary element.
537
+ if (shouldFullLoadDuringParse(isPopState, frameId) && typeof location !== 'undefined') {
538
+ reportFallback('readyState-loading', href);
539
+ hardNavigate(href);
540
+ return null;
541
+ }
542
+
543
+ // Cancel any in-flight fetch: Turbo Drive's navigator.stop().
544
+ if (activeAbortController) activeAbortController.abort();
545
+ activeAbortController = new AbortController();
546
+ const signal = activeAbortController.signal;
547
+ // Bump nav generation. Captured below + by anything we await into.
548
+ const myToken = bumpNavToken();
549
+
550
+ // A new navigation ends any restore window still open from an earlier one
551
+ // (#1310). The window outlives its own restore by design (a floor, then a
552
+ // ceiling), so without this a second navigation inside that span inherits
553
+ // suppressed anchoring: a Back that CLAMPS opens no window of its own, so it
554
+ // would run the whole growth under the previous restore's suppression and
555
+ // freeze its clamp, and a forward nav would carry it onto a different page
556
+ // entirely. Reopening for this navigation, if it earns one, happens below.
557
+ // The height reservation is released for the same reason: it holds a height
558
+ // recorded for the page being navigated away from.
559
+ //
560
+ // A FRAME-targeted nav is excluded, for the same reason `loadFrame` is: it
561
+ // swaps one region and leaves the page, and so the restored scroll offset,
562
+ // intact. The codebase already treats a click-driven frame nav and a `src`
563
+ // self-load as the same thing, so exempting one and not the other would be
564
+ // the split this rule exists to avoid.
565
+ //
566
+ // All THREE move together. Exempting only the counter while still closing
567
+ // the window and releasing the reservation would leave the split exactly
568
+ // where it was, one line further down: a form inside a frame, submitted by a
569
+ // component upgrading in the just-restored page, would hand anchoring back
570
+ // mid-restore and bring the whole double-count back.
571
+ if (!frameId) {
572
+ bumpRestoreGeneration();
573
+ if (releaseScrollAnchor) releaseScrollAnchor();
574
+ if (releaseHeightReservation) releaseHeightReservation();
575
+ }
576
+
577
+ // Snapshot the page the user is LEAVING (with its scroll position)
578
+ // so back/forward navigation can restore it. We key under
579
+ // `currentPageUrl` rather than `location.href` because on popstate
580
+ // the browser has already updated `location.href` to the destination
581
+ // URL: using it as the key would clobber the cached snapshot we're
582
+ // about to read in the popstate-restore branch below.
583
+ //
584
+ // A refresh (#1398) skips it: it navigates to the URL it is already on, so
585
+ // snapshotting first would write the PRE-EDIT page into `snapshotCache` under
586
+ // that exact key and a later Back would restore the stale render.
587
+ if (!refresh && currentPageUrl) snapshotCurrent(currentPageUrl);
588
+
589
+ // Expose the opt-in `data-navigating` loading-indicator hook (see
590
+ // setNavigating), but only if the nav takes long enough to be worth showing
591
+ // one. Deferred so quick navs (sub-150ms) never set it at all.
592
+ let navigatingFlagTimer = setTimeout(() => {
593
+ setNavigating(true);
594
+ navigatingFlagTimer = null;
595
+ }, 150);
596
+
597
+ // Optimistic loading: clone the per-segment loading.ts template (if
598
+ // any) into the deepest current children-slot so the user sees an
599
+ // instant skeleton instead of stale content. Saved so we can restore
600
+ // it if the fetch fails.
601
+ //
602
+ // A refresh (#1398) shows none: flashing a `loading.ts` skeleton over content
603
+ // that is already correct is strictly worse than showing the old content for
604
+ // one round trip. Turbo's `MorphingPageRenderer` suppresses its own
605
+ // equivalents for the same reason.
606
+ let optimisticState = null;
607
+ if (!isPopState && !refresh) optimisticState = applyOptimisticLoading();
608
+ // Set when a popstate finds no snapshot, so the tail can re-assert the
609
+ // fallback scroll after the response commits. Declared HERE rather than in
610
+ // the branch that sets it, because the reader is outside that block and a
611
+ // `typeof` probe for a block-scoped binding is a coincidence, not a guard.
612
+ let cacheMiss = false;
613
+
614
+ try {
615
+ // popstate: try cache first, then refetch in background. Instant restore.
616
+ if (isPopState) {
617
+ const cached = snapshotGet(href);
618
+ if (cached) {
619
+ const cachedDoc = parseHTML(cached.html);
620
+ if (cachedDoc) {
621
+ // Reserve the page's SETTLED height BEFORE the swap, so the recorded
622
+ // offset is reachable from the very first frame (#1428). The snapshot
623
+ // markup is shorter than the page it came from until its components
624
+ // upgrade, and every scroll defect this path has had lived in that
625
+ // window: the offset clamped against a short document, and the
626
+ // restore had to chase it back afterwards. Reserving removes the
627
+ // shortness instead of compensating for it, so there is nothing to
628
+ // clamp and nothing to chase.
629
+ // Taken BEFORE the swap, so the height is already in place when the
630
+ // incoming (shorter) markup lands and the offset is never
631
+ // unreachable. One consequence worth knowing: under a view
632
+ // transition `applySwap` defers its mutation a frame, so the
633
+ // OUTGOING page is captured by the transition with this height
634
+ // already applied. On a page short enough that the reservation adds
635
+ // a scrollbar, that appears in the transition's old-state snapshot.
636
+ // Accepted rather than deferred, because deferring the reservation
637
+ // to the commit would reopen the window it exists to close.
638
+ const releaseHeight = reserveRestoredHeight(cached.scrollHeight);
639
+ applySwap(cachedDoc, frameId, /* revalidating */ true, /* href */ null);
640
+ // Restore window scroll to where the user left it. Use
641
+ // behavior:'instant' so an app-level `scroll-behavior: smooth`
642
+ // stylesheet does not animate the restore (native nav jumps).
643
+ //
644
+ // Anchoring is still suppressed across the restore even though the
645
+ // document no longer changes total HEIGHT: content still SHIFTS
646
+ // within the reserved space as components render, and anchoring
647
+ // reacts to a shift above the viewport regardless of whether the
648
+ // page grew overall, adding it to the offset just replayed (#1310).
649
+ // The BROWSER restores the scroll, not the router (#1428).
650
+ //
651
+ // Under `scrollRestoration: 'auto'` the UA records an offset per
652
+ // history entry and replays it a frame after the popstate handler.
653
+ // The router used to replay its own snapshot offset synchronously
654
+ // here as well, which made two writers of the same quantity and
655
+ // forced a whole apparatus to keep them from disagreeing. With the
656
+ // height reserved above, the UA's replay lands on a document that
657
+ // can hold the offset, so it is simply correct, and the router's
658
+ // write is redundant. Deleting it leaves ONE writer, which is what
659
+ // Next and Remix 3 do (neither scrolls on a traverse; Next's restore
660
+ // reducer sets `scrollRef: null`). Turbo is single-writer too, but
661
+ // the other way round: it takes `manual` and replays itself, which is
662
+ // exactly the choice that costs it the iOS gesture preview.
663
+ //
664
+ // The window below still opens, for the half the UA does NOT cover:
665
+ // content SHIFTS above the viewport as the restored components
666
+ // render, and anchoring would add that shift to the offset the UA
667
+ // just replayed. It also carries the cached offset as a write-back
668
+ // target, so a programmatic write that intrudes on the restore's own
669
+ // span is corrected rather than left standing.
670
+ let releaseAnchor = () => {};
671
+ if (typeof window !== 'undefined') {
672
+ const openWindow = () => {
673
+ releaseAnchor = suppressScrollAnchoring(cached.scrollX, cached.scrollY);
674
+ };
675
+ if (viewTransitionsEnabled() && typeof (/** @type any */ (document)).startViewTransition === 'function') {
676
+ // Under a view transition `applySwap` defers its DOM mutation a
677
+ // frame, so the window must wait for the commit or it guards the
678
+ // OUTGOING page. Guarded on the restore generation, because this
679
+ // is the one path where the restore outlives the call that
680
+ // scheduled it: a navigation, submission, or disable arriving
681
+ // inside the deferred frame closes the window, and this must not
682
+ // reopen it against a page it was never meant for.
683
+ const myRestore = restoreGeneration;
684
+ _swapCommit.then(() => {
685
+ if (myRestore !== restoreGeneration || !enabled) return;
686
+ openWindow();
687
+ }).catch(() => {});
688
+ } else {
689
+ openWindow();
690
+ }
691
+ }
692
+ // Fire-and-forget revalidation. Uses a fresh AbortController
693
+ // since this background fetch is allowed to overlap with the
694
+ // next foreground nav (it'll get aborted if a new nav lands).
695
+ //
696
+ // Closing the anchoring window on THIS revalidation's settle (plus two
697
+ // frames for the re-applied DOM to lay out) is what keeps the window
698
+ // tied to one restore. A height observer could not tell a settling
699
+ // restore from a streaming <webjs-suspense> boundary (#471 / #473).
700
+ //
701
+ // The floor is what makes that safe. Waiting on the revalidation ALONE
702
+ // ties the window's length to network latency rather than to the
703
+ // growth it guards, so a server that answers faster than the restored
704
+ // page renders closes it early and the reader lands low again, which
705
+ // is the whole defect.
706
+ const revalidated = fetchAndApply(href, frameId, /* recordHistory */ false, optimisticState, 'GET', null, signal, myToken, /* revalidating */ true)
707
+ .catch(() => {});
708
+ const floor = new Promise((r) => setTimeout(r, ANCHOR_SUPPRESS_FLOOR_MS));
709
+ // Staggered, not simultaneous. The revalidation's own swap re-inserts
710
+ // fresh markup that is SHORT again until its components upgrade,
711
+ // which is the premise the reservation exists for, so dropping the
712
+ // height in the same tick as the anchoring window can clamp the
713
+ // reader down and then let anchoring add the regrowth on top. The
714
+ // height is released a further two frames out, by which point the
715
+ // re-applied DOM has laid out, and anchoring is already back on to
716
+ // absorb whatever is left.
717
+ Promise.all([revalidated, floor]).then(() => afterTwoFrames(() => {
718
+ releaseAnchor();
719
+ afterTwoFrames(releaseHeight);
720
+ }));
721
+ return null;
722
+ }
723
+ }
724
+ // Cache-miss popstate. There is no snapshot to restore from, so the
725
+ // router falls back to top, which is what a reader gets for a page the
726
+ // cache no longer holds.
727
+ //
728
+ // Written TWICE on purpose: once now, and once after the response
729
+ // commits. The browser also replays its own recorded offset for this
730
+ // entry, about a frame after this handler, and that replay would
731
+ // otherwise be the last write. It is measured against the OUTGOING
732
+ // document and lands before the fetched content arrives, so it puts the
733
+ // reader at an offset that means nothing in the page they end up
734
+ // looking at. Under the old `manual` mode the UA replayed nothing and
735
+ // this path was deterministic; the second write is what keeps it that
736
+ // way now that the browser owns restoration.
737
+ cacheMiss = true;
738
+ if (typeof window !== 'undefined') window.scrollTo({ left: 0, top: 0, behavior: 'instant' });
739
+ }
740
+
741
+ // `recordHistory: false` on a refresh (#1398) is what suppresses BOTH the
742
+ // duplicate `history.pushState` and the whole scroll block in one flag,
743
+ // which is exactly what the comment above that block already says it means.
744
+ // So Back still goes to the previous page and the reader keeps their place.
745
+ const outcome = await fetchAndApply(href, frameId, !isPopState && !refresh, optimisticState, 'GET', null, signal, myToken, /* revalidating */ false, { refresh, preserveScroll });
746
+ // The cache-miss re-assert described above, DEFERRED two frames rather
747
+ // than written synchronously. A synchronous write here only wins when the
748
+ // fetch was slower than the UA's replay, which is most fetches but not a
749
+ // PREFETCH HIT: a warmed entry resolves the whole fetch-and-apply inside
750
+ // the popstate task, the re-assert would land in that same task, and the
751
+ // UA's replay a frame later would overwrite it, exactly the ordering this
752
+ // exists to correct. Two frames is past the replay on every tested engine
753
+ // whichever path resolved the fetch. Guarded on still being the active
754
+ // navigation INSIDE the deferred callback, so a superseded miss never
755
+ // scrolls the page that replaced it.
756
+ if (cacheMiss && outcome && outcome.applied && typeof window !== 'undefined') {
757
+ afterTwoFrames(() => {
758
+ if (myToken !== currentNavigationToken || !enabled) return;
759
+ window.scrollTo({ left: 0, top: 0, behavior: 'instant' });
760
+ });
761
+ }
762
+ return outcome;
763
+ } finally {
764
+ if (navigatingFlagTimer) clearTimeout(navigatingFlagTimer);
765
+ // Only clear the navigating flag if WE are still the active nav.
766
+ // A newer nav has its own flag lifecycle.
767
+ if (myToken === currentNavigationToken) {
768
+ setNavigating(false);
769
+ // Record where the user is NOW so the next navigation can
770
+ // snapshot under the right URL key.
771
+ if (typeof location !== 'undefined') currentPageUrl = location.href;
772
+ }
773
+ }
774
+ }
775
+
776
+ /**
777
+ * Submit a form via the partial-swap pipeline. Mirrors performNavigation
778
+ * but routes the FormData body. GET submissions promote the body to a
779
+ * query string (HTML form-submission algorithm); non-GET submissions
780
+ * send the body as-is.
781
+ *
782
+ * Mutating methods (anything except GET/HEAD) clear the whole snapshot
783
+ * cache after a successful response: Turbo's `clearSnapshotCache()` on
784
+ * `!isSafe` (`navigator.js:71-88`). Other URLs in the cache may have
785
+ * been server-side-mutated by this submission; refusing to clear would
786
+ * serve stale content on subsequent back/forward.
787
+ *
788
+ * Submission-state events + aria-busy: while the enhanced submission fetch
789
+ * is in flight the router sets `aria-busy="true"` on the FORM element and
790
+ * dispatches `webjs:submit-start` (detail `{ form, url }`); on EVERY settle
791
+ * path (success swap, validation re-render, navigation error, abort by a
792
+ * superseding submit/nav) it clears `aria-busy` and dispatches
793
+ * `webjs:submit-end` (detail `{ form, url, ok }`, `ok` = the submission was
794
+ * not an error outcome). The toggle uses the same nav-token guard the
795
+ * `<webjs-frame>` busy state uses (`formBusyTokens` / `markFormBusy` /
796
+ * `clearFormBusy`): a superseded submit's teardown never clears the busy
797
+ * state a NEWER submit already set, so a rapid re-submit stays busy until the
798
+ * live submission settles. The native `aria-busy` attribute on the form is
799
+ * the readable "is this form submitting" primitive (any component can read
800
+ * it); the events are the push-notification counterpart. Progressive
801
+ * enhancement: with JS off this whole code path is skipped and the form is a
802
+ * plain POST.
803
+ *
804
+ * @param {string} href Absolute target URL.
805
+ * @param {string} method Lowercased HTTP verb.
806
+ * @param {FormData | URLSearchParams} body Encoded per the declared enctype
807
+ * (#1307): `FormData` for multipart, `URLSearchParams` for urlencoded. Both
808
+ * iterate as `[name, value]` pairs, which is all the safe-method query-string
809
+ * promotion below needs, and `fetch` derives the right content type from
810
+ * either without an explicit header.
811
+ * @param {string | null} frameId
812
+ * @param {HTMLFormElement | null} [form] The submitted form, for busy + events.
813
+ * @param {{ preserveScroll?: boolean }} [opts] `preserveScroll` keeps the
814
+ * reader's offset instead of scrolling to top after the response applies
815
+ * (#1436), from `data-preserve-scroll` on the form or its submitter. The case
816
+ * it exists for is a long form failing validation: the 422 re-renders in
817
+ * place, and scrolling to top would move the reader away from the field that
818
+ * failed.
819
+ */
820
+ export async function performSubmission(href, method, body, frameId, form, opts) {
821
+ // A submission is real work, so any pending fragment mark is stale and must
822
+ // not survive into the popstate a later Back produces. This is exactly the
823
+ // 422 duplicate-entry path (#1437).
824
+ clearFragmentNav();
825
+ if (activeAbortController) activeAbortController.abort();
826
+ activeAbortController = new AbortController();
827
+ const signal = activeAbortController.signal;
828
+ const myToken = bumpNavToken();
829
+ // Same reasoning as performNavigation: a submission is a navigation, so it
830
+ // ends any restore window a recent Back left open (#1310), and releases its
831
+ // height reservation. Frame-targeted submissions are excluded on the
832
+ // same reasoning as the frame navs above.
833
+ if (!frameId) {
834
+ bumpRestoreGeneration();
835
+ if (releaseScrollAnchor) releaseScrollAnchor();
836
+ if (releaseHeightReservation) releaseHeightReservation();
837
+ }
838
+
839
+ const isSafe = method === 'get' || method === 'head';
840
+ let url = new URL(href, location.href);
841
+ if (isSafe) {
842
+ // Promote body to query string per the HTML5 form-submission
843
+ // algorithm. The form's own `action` query is replaced: same as
844
+ // a native GET-form submission.
845
+ url.search = '';
846
+ for (const [k, v] of body) {
847
+ url.searchParams.append(k, typeof v === 'string' ? v : v.name);
848
+ }
849
+ }
850
+
851
+ // Snapshot the page being submitted from (form submissions are
852
+ // always foreground / never popstate, so `currentPageUrl` already
853
+ // matches `location.href`: but use the tracker for consistency
854
+ // with performNavigation).
855
+ if (currentPageUrl) snapshotCurrent(currentPageUrl);
856
+
857
+ let navigatingFlagTimer = setTimeout(() => {
858
+ setNavigating(true);
859
+ navigatingFlagTimer = null;
860
+ }, 150);
861
+
862
+ const optimisticState = applyOptimisticLoading();
863
+
864
+ // Submission-state lifecycle: mark the form busy + announce the start, then
865
+ // clear + announce the settle in the finally so EVERY exit (success,
866
+ // validation re-render, navigation error, abort by a superseding submit)
867
+ // balances the pair. `ok` is filled from the fetch outcome; an abort or a
868
+ // teardown that never reached the fetch settles ok:false. The token guard
869
+ // (markFormBusy/clearFormBusy) keeps a superseded submit's teardown from
870
+ // clearing the busy state a newer submit set.
871
+ const busyForm = form ? markFormBusy(form, myToken, url.href) : null;
872
+ let outcomeOk = false;
873
+ try {
874
+ const outcome = await fetchAndApply(
875
+ url.href,
876
+ frameId,
877
+ /* recordHistory */ true,
878
+ optimisticState,
879
+ isSafe ? 'GET' : method.toUpperCase(),
880
+ isSafe ? null : body,
881
+ signal,
882
+ myToken,
883
+ /* revalidating */ false,
884
+ { preserveScroll: !!(opts && opts.preserveScroll) },
885
+ );
886
+ outcomeOk = !!(outcome && outcome.ok);
887
+ // Mutating submissions invalidate cached versions of other URLs -
888
+ // do this *after* the response applies so the new page itself is
889
+ // snapshotted on the next nav, not pre-emptively wiped. Clear the
890
+ // speculative prefetch cache too: a fragment prefetched before this
891
+ // mutation would otherwise be served stale on a later forward click.
892
+ if (!isSafe && myToken === currentNavigationToken) {
893
+ snapshotCache.clear();
894
+ prefetchClearAll();
895
+ }
896
+ } finally {
897
+ if (busyForm) clearFormBusy(busyForm, myToken, url.href, outcomeOk);
898
+ if (navigatingFlagTimer) clearTimeout(navigatingFlagTimer);
899
+ if (myToken === currentNavigationToken) {
900
+ setNavigating(false);
901
+ if (typeof location !== 'undefined') currentPageUrl = location.href;
902
+ }
903
+ }
904
+ }
905
+
906
+
907
+ /* ====================================================================
908
+ * Link prefetch (Remix-style strategies, fast-by-default)
909
+ *
910
+ * A link click already resolves through fetchAndApply, but the fetch
911
+ * only STARTS on click, so the user waits a full round-trip. Prefetch
912
+ * warms a dedicated cache speculatively so the click reads it instantly.
913
+ *
914
+ * Strategy per anchor via a `data-prefetch` attribute (valid-HTML data-*,
915
+ * like SvelteKit / Astro). The default is DEVICE-ADAPTIVE so the common case
916
+ * is fast on every device without per-link opt-in: `intent` on a hover-capable
917
+ * pointer (a real head-start before the click), `viewport` on touch (no hover
918
+ * exists, and `touchstart` fires too close to the tap to front-run it). Value
919
+ * vocabulary borrows Next's true/false/auto aliases:
920
+ * - absent (default) : intent on pointer, viewport on touch (adaptive)
921
+ * - intent : hover / focus / touch, after a short dwell
922
+ * - true / render : eager, as soon as a document scan sees it
923
+ * - auto / viewport : on viewport entry (IntersectionObserver, 0.5),
924
+ * after a dwell so a fast scroll-through skips it
925
+ * - false / none : never (also data-no-prefetch / rel="external")
926
+ *
927
+ * Why a separate cache, not snapshotCache: snapshotCache is keyed to the
928
+ * back/forward restore path (popstate), which holds the FULL serialized
929
+ * document of pages the user already visited. A prefetch holds the
930
+ * SERVER FRAGMENT for a page not yet visited (the same X-Webjs-Have
931
+ * partial body a real nav would receive). fetchAndApply consumes it via
932
+ * prefetchTake() before falling back to the network.
933
+ *
934
+ * Only same-origin in-app links are prefetched (the same eligibility as
935
+ * a click), and never under Save-Data / prefers-reduced-data / a 2g link,
936
+ * never past a small concurrency cap, and never twice (deduped + cached). The
937
+ * viewport path additionally waits a dwell and cancels on scroll-out, so a
938
+ * fast scroll through a long list does not flood the network tab. There is no
939
+ * logout-style heuristic: prefetch issues a real GET, so as everywhere in
940
+ * the ecosystem (Next / Nuxt / Remix), a non-idempotent action must be a
941
+ * POST or a `<form>`, and `data-no-prefetch` / `rel="external"` opt out.
942
+ *
943
+ * What we do NOT touch: a native `<link rel="prefetch">` in the document
944
+ * head is the browser's own mechanism and warms the HTTP cache; we never
945
+ * interfere with it.
946
+ * ==================================================================== */
947
+
948
+ /** Test-only: read the "current page URL" tracker (used for snapshot keying). */
949
+ export function _currentPageUrl() { return currentPageUrl; }
950
+
951
+ /** Test-only: set the tracker (simulates being on a specific page). */
952
+ export function _setCurrentPageUrl(u) { currentPageUrl = u; }