@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.
- 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 +3 -3
- 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/css.d.ts +9 -0
- package/src/escape.d.ts +2 -0
- package/src/html.d.ts +9 -0
- package/src/render-client/parts.js +2039 -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 +938 -0
- package/src/render-server/text.js +163 -0
- package/src/render-server.js +6 -2405
- package/src/repeat.d.ts +15 -0
- package/src/rich-fetch.d.ts +4 -0
- package/src/router-client/anchors.js +44 -0
- package/src/router-client/boundaries.js +221 -0
- package/src/router-client/constants.js +206 -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 +182 -0
- package/src/router-client/fetch-apply.js +432 -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 +952 -0
- package/src/router-client/prefetch.js +831 -0
- package/src/router-client/scroll.js +308 -0
- package/src/router-client/snapshot-cache.js +113 -0
- package/src/router-client/state.js +155 -0
- package/src/router-client/stream.js +206 -0
- package/src/router-client/swap.js +454 -0
- package/src/router-client/upgrade.js +261 -0
- package/src/router-client/view-transition.js +223 -0
- package/src/router-client.d.ts +11 -2
- package/src/router-client.js +78 -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
- package/src/suspense.d.ts +9 -0
- 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; }
|