@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.
@@ -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, cancelScrollCatchUp, catchUpToRestoredScroll, releaseScrollAnchor, restoreGeneration, suppressScrollAnchoring } from './scroll.js';
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
- * Previous value of `history.scrollRestoration` (so we can restore it
55
- * when the router is disabled). The browser's default behavior of
56
- * auto-restoring scroll on popstate races with the SPA's own scroll
57
- * restoration: disabled here so WebJs is the sole authority on scroll
58
- * during navigation. Same pattern as Turbo Drive's
59
- * `assumeControlOfScrollRestoration()` (turbo/src/core/drive/history.js).
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
- export let prevScrollRestoration = null;
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
- // Take control of scroll restoration so the browser doesn't fight
109
- // the SPA's own snapshot-based restore on popstate.
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 = 'manual';
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 catch-up chasing a
144
- // scroll offset after the router is gone (#1310).
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 (cancelScrollCatchUp) cancelScrollCatchUp();
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
- /* noPrefetch */ true,
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] `refresh` marks a same-URL
388
- * in-place re-render (#1398). It suppresses three things a forward navigation
389
- * does and a refresh must not: the outgoing snapshot, the optimistic loading
390
- * skeleton, and history plus scroll. See `refreshPage`.
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 clamped path's catch-up is cancelled for the same reason: it chases an
433
- // offset recorded for the page being navigated away from.
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 aborting the catch-up would leave the split exactly where
443
- // it was, one line further down: a form inside a frame, submitted by a
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 (cancelScrollCatchUp) cancelScrollCatchUp();
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
- // `cached.scrollY` was recorded at the page's SETTLED height, and the
497
- // DOM just swapped in is still shorter until its components upgrade
498
- // and re-render. Suppress scroll anchoring across the restore, or the
499
- // browser adds that late growth to the restored offset and the reader
500
- // lands below where they left (#1310).
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
- // Restore the scroll, then decide whether to suppress anchoring.
504
- //
505
- // Suppress ONLY when the recorded offset was actually reached. A
506
- // document that has not grown yet can be too SHORT to scroll that
507
- // far, and the browser clamps to its current maximum. A reader at
508
- // the bottom of the settled page is the clear case: the shortfall is
509
- // then exactly the growth still to come, and anchoring ADDING that
510
- // growth is what carries them back to the bottom. Suppressing there
511
- // freezes the clamp instead and strands them a full page-growth
512
- // ABOVE where they left, which is this bug's own mirror image. The
513
- // two situations want opposite things and are told apart by the one
514
- // question that separates them: did the scroll land.
515
- //
516
- // Both halves must read the SAME layout, and the scroll must be
517
- // written against the page being restored. That is why this is
518
- // ordered rather than simply inlined, and why the ordering differs
519
- // by path.
520
- const restoreScroll = () => {
521
- window.scrollTo({ left: cached.scrollX, top: cached.scrollY, behavior: 'instant' });
522
- if (window.scrollY >= cached.scrollY - 1) {
523
- releaseAnchor = suppressScrollAnchoring();
524
- } else {
525
- // Clamped. Anchoring is left on, since it is what carries the
526
- // reader back down, but it adds the FULL growth regardless of
527
- // how far short the clamp fell, so on its own it only lands a
528
- // reader who left at the very bottom. Chase the recorded offset
529
- // instead, once the page is tall enough to hold it.
530
- catchUpToRestoredScroll(cached.scrollY, cached.scrollX);
531
- }
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 running now would write and measure against the
536
- // OUTGOING page. Measured with a 60000px outgoing page and a
537
- // 3000px restored one: the scroll "landed" at 20000, suppression
538
- // opened, and the restored page then clamped to 2416 with
539
- // anchoring held off, which is precisely the stranding the
540
- // conditional exists to prevent. Wait for the swap to commit.
541
- //
542
- // Guarded, because this is the one path where the restore
543
- // outlives the call that scheduled it. Every cancel site in this
544
- // feature (performNavigation, performSubmission,
545
- // disableClientRouter) runs at the START of the next thing, so a
546
- // navigation, submission, or disable arriving inside the deferred
547
- // frame would close the window and then have this reopen it,
548
- // scrolling a page it was never meant for to an offset recorded
549
- // for the previous history entry. The synchronous branch below
550
- // cannot outlive anything and so needs no guard.
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
- restoreScroll();
686
+ openWindow();
555
687
  }).catch(() => {});
556
688
  } else {
557
- // The synchronous path, and the read must STAY synchronous here.
558
- // Deferring it even by a microtask breaks the fix outright: by
559
- // then the restored components' renders have been applied, and
560
- // reading `scrollY` forces the layout that flushes them, so
561
- // anchoring runs DURING the read and hands back the
562
- // already-shifted offset. Measured on /ui/button, the suppression
563
- // landed 19ms late with `scrollY` already 800 -> 1563. What makes
564
- // it correct is not which document it sees but that it sees the
565
- // same layout the scroll just landed in.
566
- restoreScroll();
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
- Promise.all([revalidated, floor]).then(() => afterTwoFrames(releaseAnchor));
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. Browser-native scroll restoration is
591
- // disabled (we set scrollRestoration='manual'): so without
592
- // explicit handling, scroll would just stay where the user was
593
- // on the page they popped FROM. Scroll to top as the reasonable
594
- // default; fetchAndApply skips its own scroll handling when
595
- // recordHistory=false (which is the case here).
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
- return await fetchAndApply(href, frameId, !isPopState && !refresh, optimisticState, 'GET', null, signal, myToken, /* revalidating */ false, refresh);
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 cancels a
662
- // clamped restore's catch-up. Frame-targeted submissions are excluded on the
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 (cancelScrollCatchUp) cancelScrollCatchUp();
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. Shared by onClick and
217
- * the prefetch listeners so eligibility never drifts between them.
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 hash jump is not a navigation we fetch.
233
- if (url.pathname === location.pathname && url.search === location.search && url.hash) return null;
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
  }