@webjsdev/core 0.7.50 → 0.7.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +20 -20
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -7
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +1 -1
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/component/lifecycle.js +1481 -0
- package/src/component/reactive.js +171 -0
- package/src/component/server-element.js +248 -0
- package/src/component.js +2 -1896
- package/src/render-client/parts.js +2007 -0
- package/src/render-client/reconciler.js +388 -0
- package/src/render-client/template-compiler.js +525 -0
- package/src/render-client.js +1 -2938
- package/src/render-server/attrs.js +160 -0
- package/src/render-server/dsd.js +445 -0
- package/src/render-server/html-scan.js +402 -0
- package/src/render-server/slots.js +213 -0
- package/src/render-server/stream.js +163 -0
- package/src/render-server/template-renderer.js +923 -0
- package/src/render-server/text.js +163 -0
- package/src/render-server.js +6 -2405
- package/src/router-client/anchors.js +44 -0
- package/src/router-client/boundaries.js +221 -0
- package/src/router-client/constants.js +187 -0
- package/src/router-client/diagnostics.js +377 -0
- package/src/router-client/dom-differ.js +709 -0
- package/src/router-client/dom-parse.js +150 -0
- package/src/router-client/events.js +139 -0
- package/src/router-client/fetch-apply.js +387 -0
- package/src/router-client/form-encoder.js +135 -0
- package/src/router-client/frames.js +329 -0
- package/src/router-client/head-merge.js +344 -0
- package/src/router-client/nav-error.js +105 -0
- package/src/router-client/navigator.js +781 -0
- package/src/router-client/prefetch.js +824 -0
- package/src/router-client/scroll.js +259 -0
- package/src/router-client/snapshot-cache.js +97 -0
- package/src/router-client/state.js +92 -0
- package/src/router-client/stream.js +206 -0
- package/src/router-client/swap.js +451 -0
- package/src/router-client/upgrade.js +261 -0
- package/src/router-client/view-transition.js +223 -0
- package/src/router-client.d.ts +10 -1
- package/src/router-client.js +75 -5345
- package/src/slot/assignment.js +304 -0
- package/src/slot/interception.js +558 -0
- package/src/slot/polyfills.js +365 -0
- package/src/slot/project.js +432 -0
- package/src/slot/sensors.js +303 -0
- package/src/slot/state.js +245 -0
- package/src/slot/symbols.js +85 -0
- package/src/slot.js +7 -2199
|
@@ -0,0 +1,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; }
|