@webjsdev/core 0.7.51 → 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 +14 -14
- package/dist/webjs-core-testing.js.map +1 -1
- package/dist/webjs-core.js +7 -7
- package/dist/webjs-core.js.map +3 -3
- package/index.d.ts +2 -2
- package/package.json +1 -1
- 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 +34 -2
- package/src/render-server/template-renderer.js +15 -0
- package/src/repeat.d.ts +15 -0
- package/src/rich-fetch.d.ts +4 -0
- package/src/router-client/constants.js +25 -6
- package/src/router-client/events.js +49 -6
- package/src/router-client/fetch-apply.js +71 -26
- package/src/router-client/navigator.js +272 -101
- package/src/router-client/prefetch.js +11 -4
- package/src/router-client/scroll.js +169 -120
- package/src/router-client/snapshot-cache.js +18 -2
- package/src/router-client/state.js +63 -0
- package/src/router-client/swap.js +7 -4
- package/src/router-client.d.ts +1 -1
- package/src/router-client.js +3 -0
- package/src/suspense.d.ts +9 -0
- package/src/websocket-client.d.ts +22 -0
|
@@ -17,9 +17,9 @@ import { clearPrefetchHover, clearPrefetchRefused, clearPrefetchViewTimers, onPr
|
|
|
17
17
|
// `restoreGeneration` is imported READ-ONLY: the deferred restore captures it
|
|
18
18
|
// and re-compares after the frame, so it must be the live binding. Writes go
|
|
19
19
|
// through bumpRestoreGeneration(), since ESM forbids assigning an import.
|
|
20
|
-
import { afterTwoFrames, bumpRestoreGeneration,
|
|
20
|
+
import { afterTwoFrames, bumpRestoreGeneration, releaseHeightReservation, releaseScrollAnchor, reserveRestoredHeight, restoreGeneration, suppressScrollAnchoring } from './scroll.js';
|
|
21
21
|
import { snapshotCache, snapshotCurrent, snapshotGet } from './snapshot-cache.js';
|
|
22
|
-
import { _setEnabled, bumpNavToken, currentNavigationToken, enabled, hardNavigate } from './state.js';
|
|
22
|
+
import { _setEnabled, bumpNavToken, clearFragmentNav, consumeFragmentNav, currentNavigationToken, enabled, hardNavigate } from './state.js';
|
|
23
23
|
import { _swapCommit, applySwap } from './swap.js';
|
|
24
24
|
import { ensureUpgradeObserver } from './upgrade.js';
|
|
25
25
|
import { viewTransitionsEnabled } from './view-transition.js';
|
|
@@ -51,16 +51,92 @@ let activeAbortController = null;
|
|
|
51
51
|
let currentPageUrl = null;
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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.
|
|
60
136
|
*
|
|
61
137
|
* @type {ScrollRestoration | null}
|
|
62
138
|
*/
|
|
63
|
-
|
|
139
|
+
let prevScrollRestoration = null;
|
|
64
140
|
|
|
65
141
|
/** Enable the client router. Idempotent. */
|
|
66
142
|
export function enableClientRouter() {
|
|
@@ -105,11 +181,36 @@ export function enableClientRouter() {
|
|
|
105
181
|
ensureUpgradeObserver();
|
|
106
182
|
// Apply render/viewport prefetch modes to the initial document.
|
|
107
183
|
refreshPrefetchObservers();
|
|
108
|
-
//
|
|
109
|
-
//
|
|
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.
|
|
110
211
|
if (typeof history !== 'undefined' && 'scrollRestoration' in history) {
|
|
111
212
|
prevScrollRestoration = history.scrollRestoration;
|
|
112
|
-
history.scrollRestoration = '
|
|
213
|
+
history.scrollRestoration = 'auto';
|
|
113
214
|
}
|
|
114
215
|
// Seed the "current page" tracker so the first navigation can
|
|
115
216
|
// snapshot the page the user is leaving.
|
|
@@ -128,6 +229,12 @@ export function disableClientRouter() {
|
|
|
128
229
|
document.removeEventListener('click', onClick, false);
|
|
129
230
|
document.removeEventListener('submit', onSubmit, false);
|
|
130
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();
|
|
131
238
|
document.removeEventListener('pointerover', onPrefetchIntent, true);
|
|
132
239
|
document.removeEventListener('focusin', onPrefetchIntent, true);
|
|
133
240
|
document.removeEventListener('touchstart', onPrefetchIntent, /** @type any */ ({ capture: true }));
|
|
@@ -136,15 +243,16 @@ export function disableClientRouter() {
|
|
|
136
243
|
clearPrefetchHover();
|
|
137
244
|
clearPrefetchViewTimers();
|
|
138
245
|
teardownPrefetchViewObserver();
|
|
246
|
+
// Give the app back the scroll-restoration mode the router overrode.
|
|
139
247
|
if (typeof history !== 'undefined' && prevScrollRestoration !== null) {
|
|
140
248
|
history.scrollRestoration = prevScrollRestoration;
|
|
141
249
|
prevScrollRestoration = null;
|
|
142
250
|
}
|
|
143
|
-
// Never leave a restore window open on <html>, nor a
|
|
144
|
-
//
|
|
251
|
+
// Never leave a restore window open on <html>, nor a height reservation
|
|
252
|
+
// held on it, after the router is gone (#1310 / #1428).
|
|
145
253
|
bumpRestoreGeneration();
|
|
146
254
|
if (releaseScrollAnchor) releaseScrollAnchor();
|
|
147
|
-
if (
|
|
255
|
+
if (releaseHeightReservation) releaseHeightReservation();
|
|
148
256
|
currentPageUrl = null;
|
|
149
257
|
// Unpublish the refresh entry (#1398) so the dev reload client's feature
|
|
150
258
|
// detection sees the router is gone and falls back to a full reload.
|
|
@@ -153,8 +261,15 @@ export function disableClientRouter() {
|
|
|
153
261
|
|
|
154
262
|
/**
|
|
155
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
|
+
*
|
|
156
271
|
* @param {string} url
|
|
157
|
-
* @param {{ replace?: boolean }} [opts]
|
|
272
|
+
* @param {{ replace?: boolean, scroll?: boolean }} [opts]
|
|
158
273
|
*/
|
|
159
274
|
export async function navigate(url, opts) {
|
|
160
275
|
const target = new URL(url, location.href);
|
|
@@ -164,7 +279,9 @@ export async function navigate(url, opts) {
|
|
|
164
279
|
hardNavigate(url);
|
|
165
280
|
return;
|
|
166
281
|
}
|
|
167
|
-
await performNavigation(target.href, opts?.replace ?? false, null
|
|
282
|
+
await performNavigation(target.href, opts?.replace ?? false, null, {
|
|
283
|
+
preserveScroll: opts?.scroll === false,
|
|
284
|
+
});
|
|
168
285
|
}
|
|
169
286
|
|
|
170
287
|
/**
|
|
@@ -232,14 +349,13 @@ export async function loadFrame(frameEl, url) {
|
|
|
232
349
|
signal,
|
|
233
350
|
myToken,
|
|
234
351
|
/* revalidating */ false,
|
|
235
|
-
/* refresh */ undefined,
|
|
236
352
|
// A self-load never consumes a speculative entry (#1407). Dropping the
|
|
237
353
|
// blanket `!frameId` guard from the consume check made this path eligible
|
|
238
354
|
// for the first time, and it should not be: a `src` self-load or a `src`
|
|
239
355
|
// mutation is the app asking for THIS frame's content now, which is a
|
|
240
356
|
// freshness request, not the click-follows-hover shape the warm cache
|
|
241
357
|
// exists to serve. Deliberately out of scope for that change.
|
|
242
|
-
|
|
358
|
+
{ noPrefetch: true },
|
|
243
359
|
);
|
|
244
360
|
|
|
245
361
|
// A self-load is a freshness request, so it does BOTH halves (#1407): it
|
|
@@ -384,10 +500,15 @@ export async function refreshPage(mode) {
|
|
|
384
500
|
* @param {string} href
|
|
385
501
|
* @param {boolean} isPopState
|
|
386
502
|
* @param {string | null} frameId Active <webjs-frame> id, or null.
|
|
387
|
-
* @param {{ refresh?: 'page' | 'shell' }} [opts]
|
|
388
|
-
* in-place re-render (#1398). It suppresses three
|
|
389
|
-
* does and a refresh must not: the outgoing
|
|
390
|
-
* skeleton, and history plus scroll. See
|
|
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.
|
|
391
512
|
* @returns {Promise<{ ok: boolean, status: number | null, aborted: boolean, applied: boolean } | null>}
|
|
392
513
|
* The `fetchAndApply` outcome, so a caller can tell an applied navigation from
|
|
393
514
|
* a failed one (#1398). Read `applied` rather than `ok` for that question: an
|
|
@@ -398,7 +519,11 @@ export async function refreshPage(mode) {
|
|
|
398
519
|
* a failure. Every other caller ignores the value.
|
|
399
520
|
*/
|
|
400
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();
|
|
401
525
|
const refresh = (opts && opts.refresh) || undefined;
|
|
526
|
+
const preserveScroll = !!(opts && opts.preserveScroll);
|
|
402
527
|
// #1008 / #936: a forward, main-document nav fired while the document is
|
|
403
528
|
// still parsing (`readyState === 'loading'`) races the DOM. The leaving
|
|
404
529
|
// page's closing layout markers at the bottom of the body may not exist yet,
|
|
@@ -429,8 +554,8 @@ export async function performNavigation(href, isPopState, frameId, opts) {
|
|
|
429
554
|
// would run the whole growth under the previous restore's suppression and
|
|
430
555
|
// freeze its clamp, and a forward nav would carry it onto a different page
|
|
431
556
|
// entirely. Reopening for this navigation, if it earns one, happens below.
|
|
432
|
-
// The
|
|
433
|
-
//
|
|
557
|
+
// The height reservation is released for the same reason: it holds a height
|
|
558
|
+
// recorded for the page being navigated away from.
|
|
434
559
|
//
|
|
435
560
|
// A FRAME-targeted nav is excluded, for the same reason `loadFrame` is: it
|
|
436
561
|
// swaps one region and leaves the page, and so the restored scroll offset,
|
|
@@ -439,14 +564,14 @@ export async function performNavigation(href, isPopState, frameId, opts) {
|
|
|
439
564
|
// the split this rule exists to avoid.
|
|
440
565
|
//
|
|
441
566
|
// All THREE move together. Exempting only the counter while still closing
|
|
442
|
-
// the window and
|
|
443
|
-
// it was, one line further down: a form inside a frame, submitted by a
|
|
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
|
|
444
569
|
// component upgrading in the just-restored page, would hand anchoring back
|
|
445
570
|
// mid-restore and bring the whole double-count back.
|
|
446
571
|
if (!frameId) {
|
|
447
572
|
bumpRestoreGeneration();
|
|
448
573
|
if (releaseScrollAnchor) releaseScrollAnchor();
|
|
449
|
-
if (
|
|
574
|
+
if (releaseHeightReservation) releaseHeightReservation();
|
|
450
575
|
}
|
|
451
576
|
|
|
452
577
|
// Snapshot the page the user is LEAVING (with its scroll position)
|
|
@@ -480,6 +605,11 @@ export async function performNavigation(href, isPopState, frameId, opts) {
|
|
|
480
605
|
// equivalents for the same reason.
|
|
481
606
|
let optimisticState = null;
|
|
482
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;
|
|
483
613
|
|
|
484
614
|
try {
|
|
485
615
|
// popstate: try cache first, then refetch in background. Instant restore.
|
|
@@ -488,82 +618,75 @@ export async function performNavigation(href, isPopState, frameId, opts) {
|
|
|
488
618
|
if (cached) {
|
|
489
619
|
const cachedDoc = parseHTML(cached.html);
|
|
490
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);
|
|
491
639
|
applySwap(cachedDoc, frameId, /* revalidating */ true, /* href */ null);
|
|
492
640
|
// Restore window scroll to where the user left it. Use
|
|
493
641
|
// behavior:'instant' so an app-level `scroll-behavior: smooth`
|
|
494
642
|
// stylesheet does not animate the restore (native nav jumps).
|
|
495
643
|
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
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.
|
|
501
670
|
let releaseAnchor = () => {};
|
|
502
671
|
if (typeof window !== 'undefined') {
|
|
503
|
-
|
|
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
|
-
}
|
|
672
|
+
const openWindow = () => {
|
|
673
|
+
releaseAnchor = suppressScrollAnchoring(cached.scrollX, cached.scrollY);
|
|
532
674
|
};
|
|
533
675
|
if (viewTransitionsEnabled() && typeof (/** @type any */ (document)).startViewTransition === 'function') {
|
|
534
676
|
// Under a view transition `applySwap` defers its DOM mutation a
|
|
535
|
-
// frame, so
|
|
536
|
-
// OUTGOING page.
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
//
|
|
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.
|
|
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.
|
|
551
683
|
const myRestore = restoreGeneration;
|
|
552
684
|
_swapCommit.then(() => {
|
|
553
685
|
if (myRestore !== restoreGeneration || !enabled) return;
|
|
554
|
-
|
|
686
|
+
openWindow();
|
|
555
687
|
}).catch(() => {});
|
|
556
688
|
} else {
|
|
557
|
-
|
|
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();
|
|
689
|
+
openWindow();
|
|
567
690
|
}
|
|
568
691
|
}
|
|
569
692
|
// Fire-and-forget revalidation. Uses a fresh AbortController
|
|
@@ -583,16 +706,35 @@ export async function performNavigation(href, isPopState, frameId, opts) {
|
|
|
583
706
|
const revalidated = fetchAndApply(href, frameId, /* recordHistory */ false, optimisticState, 'GET', null, signal, myToken, /* revalidating */ true)
|
|
584
707
|
.catch(() => {});
|
|
585
708
|
const floor = new Promise((r) => setTimeout(r, ANCHOR_SUPPRESS_FLOOR_MS));
|
|
586
|
-
|
|
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
|
+
}));
|
|
587
721
|
return null;
|
|
588
722
|
}
|
|
589
723
|
}
|
|
590
|
-
// Cache-miss popstate.
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
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;
|
|
596
738
|
if (typeof window !== 'undefined') window.scrollTo({ left: 0, top: 0, behavior: 'instant' });
|
|
597
739
|
}
|
|
598
740
|
|
|
@@ -600,7 +742,24 @@ export async function performNavigation(href, isPopState, frameId, opts) {
|
|
|
600
742
|
// duplicate `history.pushState` and the whole scroll block in one flag,
|
|
601
743
|
// which is exactly what the comment above that block already says it means.
|
|
602
744
|
// So Back still goes to the previous page and the reader keeps their place.
|
|
603
|
-
|
|
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;
|
|
604
763
|
} finally {
|
|
605
764
|
if (navigatingFlagTimer) clearTimeout(navigatingFlagTimer);
|
|
606
765
|
// Only clear the navigating flag if WE are still the active nav.
|
|
@@ -651,20 +810,30 @@ export async function performNavigation(href, isPopState, frameId, opts) {
|
|
|
651
810
|
* either without an explicit header.
|
|
652
811
|
* @param {string | null} frameId
|
|
653
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.
|
|
654
819
|
*/
|
|
655
|
-
export async function performSubmission(href, method, body, frameId, form) {
|
|
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();
|
|
656
825
|
if (activeAbortController) activeAbortController.abort();
|
|
657
826
|
activeAbortController = new AbortController();
|
|
658
827
|
const signal = activeAbortController.signal;
|
|
659
828
|
const myToken = bumpNavToken();
|
|
660
829
|
// Same reasoning as performNavigation: a submission is a navigation, so it
|
|
661
|
-
// ends any restore window a recent Back left open (#1310), and
|
|
662
|
-
//
|
|
830
|
+
// ends any restore window a recent Back left open (#1310), and releases its
|
|
831
|
+
// height reservation. Frame-targeted submissions are excluded on the
|
|
663
832
|
// same reasoning as the frame navs above.
|
|
664
833
|
if (!frameId) {
|
|
665
834
|
bumpRestoreGeneration();
|
|
666
835
|
if (releaseScrollAnchor) releaseScrollAnchor();
|
|
667
|
-
if (
|
|
836
|
+
if (releaseHeightReservation) releaseHeightReservation();
|
|
668
837
|
}
|
|
669
838
|
|
|
670
839
|
const isSafe = method === 'get' || method === 'head';
|
|
@@ -711,6 +880,8 @@ export async function performSubmission(href, method, body, frameId, form) {
|
|
|
711
880
|
isSafe ? null : body,
|
|
712
881
|
signal,
|
|
713
882
|
myToken,
|
|
883
|
+
/* revalidating */ false,
|
|
884
|
+
{ preserveScroll: !!(opts && opts.preserveScroll) },
|
|
714
885
|
);
|
|
715
886
|
outcomeOk = !!(outcome && outcome.ok);
|
|
716
887
|
// Mutating submissions invalidate cached versions of other URLs -
|
|
@@ -213,8 +213,9 @@ function relTokens(anchor) {
|
|
|
213
213
|
|
|
214
214
|
/**
|
|
215
215
|
* Decide whether an anchor is a same-origin in-app target the router can
|
|
216
|
-
* navigate, returning its absolute href or null.
|
|
217
|
-
*
|
|
216
|
+
* navigate, returning its absolute href or null. Used by the prefetch
|
|
217
|
+
* listeners, and it MIRRORS the filtering `onClick` performs inline rather
|
|
218
|
+
* than being shared with it, so the two must be changed together.
|
|
218
219
|
*
|
|
219
220
|
* @param {Element | null} anchor
|
|
220
221
|
* @returns {string | null}
|
|
@@ -229,8 +230,14 @@ export function eligibleAnchorHref(anchor) {
|
|
|
229
230
|
let url;
|
|
230
231
|
try { url = new URL(href); } catch { return null; }
|
|
231
232
|
if (url.origin !== location.origin) return null;
|
|
232
|
-
// A pure same-page
|
|
233
|
-
|
|
233
|
+
// A pure same-page fragment jump is not a navigation we fetch. Tested by
|
|
234
|
+
// `href` rather than by `hash` for the reason spelled out at the matching
|
|
235
|
+
// line in `events.js` (a null and an empty fragment both read as `''`). These
|
|
236
|
+
// two lines must stay in lockstep: `onClick` keeps its own copy rather than
|
|
237
|
+
// calling this, so drift here means a link the click path ignores becomes
|
|
238
|
+
// prefetch-eligible. #1106 already refuses the current url in every
|
|
239
|
+
// dimension, so this is drift prevention, not a change in what is fetched.
|
|
240
|
+
if (url.pathname === location.pathname && url.search === location.search && url.href.includes('#')) return null;
|
|
234
241
|
if (NON_HTML_EXTENSIONS.test(url.pathname)) return null;
|
|
235
242
|
return href;
|
|
236
243
|
}
|