@moku-labs/web 1.6.0 → 1.6.1

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/browser.mjs CHANGED
@@ -3636,15 +3636,28 @@ async function performNavigation(pathname, handlers) {
3636
3636
  * Run a DOM-mutating swap, optionally wrapped in the View Transitions API when
3637
3637
  * enabled and supported (instant swap otherwise — never throws).
3638
3638
  *
3639
+ * `beforeCapture` runs synchronously immediately before the swap — for the View
3640
+ * Transitions path that is before `startViewTransition` captures the "old" snapshot.
3641
+ * Scroll restoration belongs HERE, not in the router after the navigation: setting
3642
+ * the destination scroll at this moment means the old and new snapshots share the
3643
+ * same scrollY, so there is no scroll delta for the transition to animate and a
3644
+ * `position: sticky` header never un-pins (the cross-engine flicker, worst on WebKit).
3645
+ * Because the swap is post-fetch, doing it here also avoids the visible "scroll up,
3646
+ * THEN the page loads" pause that scrolling in the router (pre-fetch) produced.
3647
+ *
3639
3648
  * @param doSwap - The synchronous DOM mutation to perform.
3640
3649
  * @param viewTransitions - Whether to wrap the swap in `startViewTransition`.
3650
+ * @param beforeCapture - Optional hook run synchronously just before the swap/capture
3651
+ * (e.g. scroll to the destination position).
3641
3652
  * @example
3642
- * runSwap(() => current.replaceWith(next), true);
3653
+ * runSwap(() => current.replaceWith(next), true, () => scrollTo({ top: 0, behavior: "instant" }));
3643
3654
  */
3644
- function runSwap(doSwap, viewTransitions) {
3655
+ function runSwap(doSwap, viewTransitions, beforeCapture) {
3645
3656
  const reduced = typeof globalThis.matchMedia === "function" && globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches;
3646
3657
  const docWithVt = document;
3647
- if (viewTransitions && !reduced && typeof docWithVt.startViewTransition === "function") docWithVt.startViewTransition(doSwap);
3658
+ const canUseViewTransitions = viewTransitions && !reduced && typeof docWithVt.startViewTransition === "function";
3659
+ beforeCapture?.();
3660
+ if (canUseViewTransitions) docWithVt.startViewTransition(doSwap);
3648
3661
  else doSwap();
3649
3662
  }
3650
3663
  /**
@@ -3657,17 +3670,19 @@ function runSwap(doSwap, viewTransitions) {
3657
3670
  * @param swapSelector - CSS selector for the region to replace.
3658
3671
  * @param viewTransitions - Whether to wrap the swap in `startViewTransition`.
3659
3672
  * @param onSwapped - Callback run after the DOM mutation (mount/notify/scroll).
3673
+ * @param beforeCapture - Optional hook run synchronously just before the swap/capture
3674
+ * (forwarded to {@link runSwap} — e.g. scroll to the destination position).
3660
3675
  * @example
3661
3676
  * swapRegion(doc, "main > section", false, () => mountNew());
3662
3677
  */
3663
- function swapRegion(doc, swapSelector, viewTransitions, onSwapped) {
3678
+ function swapRegion(doc, swapSelector, viewTransitions, onSwapped, beforeCapture) {
3664
3679
  const newContent = doc.querySelector(swapSelector);
3665
3680
  const currentContent = document.querySelector(swapSelector);
3666
3681
  if (!newContent || !currentContent) return;
3667
3682
  runSwap(() => {
3668
3683
  currentContent.replaceWith(newContent);
3669
3684
  onSwapped();
3670
- }, viewTransitions);
3685
+ }, viewTransitions, beforeCapture);
3671
3686
  }
3672
3687
  /**
3673
3688
  * Resolve a navigable internal URL from a click event, or `undefined` when the
@@ -3722,7 +3737,7 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
3722
3737
  }
3723
3738
  saveScrollPosition(location.pathname);
3724
3739
  history.pushState({ scrollY: 0 }, "", url.pathname);
3725
- navigate(url.pathname).then(() => window.scrollTo(0, 0)).catch(() => {});
3740
+ navigate(url.pathname).catch(() => {});
3726
3741
  };
3727
3742
  /**
3728
3743
  * Re-run navigation on back/forward, restoring the saved scroll position.
@@ -3731,7 +3746,7 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
3731
3746
  * globalThis.addEventListener("popstate", onPopState);
3732
3747
  */
3733
3748
  const onPopState = () => {
3734
- navigate(location.pathname).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
3749
+ navigate(location.pathname, false).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
3735
3750
  };
3736
3751
  document.addEventListener("click", onClick);
3737
3752
  globalThis.addEventListener("popstate", onPopState);
@@ -3775,9 +3790,10 @@ function attachNavigationApi(navigation, handlers, navigate = (pathname) => perf
3775
3790
  navEvent.intercept({
3776
3791
  scroll: "manual",
3777
3792
  handler: async () => {
3778
- await navigate(url.pathname);
3779
- if (navEvent.navigationType === "traverse") navEvent.scroll();
3780
- else window.scrollTo(0, 0);
3793
+ if (navEvent.navigationType === "traverse") {
3794
+ await navigate(url.pathname, false);
3795
+ navEvent.scroll();
3796
+ } else await navigate(url.pathname);
3781
3797
  }
3782
3798
  });
3783
3799
  };
@@ -3936,6 +3952,25 @@ function syncDataHead(route, routeContext) {
3936
3952
  function createSpaKernel(state, config, emit, deps) {
3937
3953
  const resolved = resolveSpaConfig(config);
3938
3954
  let progress;
3955
+ let pendingScrollToTop = true;
3956
+ /**
3957
+ * Apply the in-flight navigation's scroll intent — the swap's `beforeCapture` hook.
3958
+ * For a forward nav it scrolls to top BEFORE the snapshot is captured, so the old and
3959
+ * new states share scrollY=0 (no delta → the sticky header never un-pins) and there is
3960
+ * no pre-fetch scroll pause. `behavior: "instant"` defeats a page-level
3961
+ * `scroll-behavior: smooth` that would otherwise animate the reset and re-create the
3962
+ * delta. Traverse (back/forward) sets `pendingScrollToTop = false` and restores its
3963
+ * saved position after the swap instead.
3964
+ *
3965
+ * @example
3966
+ * runSwap(renderAndMount, viewTransitions, applyPendingScroll);
3967
+ */
3968
+ const applyPendingScroll = () => {
3969
+ if (pendingScrollToTop) window.scrollTo({
3970
+ top: 0,
3971
+ behavior: "instant"
3972
+ });
3973
+ };
3939
3974
  /**
3940
3975
  * Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
3941
3976
  *
@@ -3951,7 +3986,7 @@ function createSpaKernel(state, config, emit, deps) {
3951
3986
  swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
3952
3987
  scanAndMount(state, emit, resolved.swapSelector);
3953
3988
  notifyNavEnd(state);
3954
- });
3989
+ }, applyPendingScroll);
3955
3990
  state.currentUrl = pathname;
3956
3991
  progress?.done();
3957
3992
  emit("spa:navigated", { url: pathname });
@@ -4046,7 +4081,7 @@ function createSpaKernel(state, config, emit, deps) {
4046
4081
  *
4047
4082
  * @example
4048
4083
  * ```ts
4049
- * runSwap(renderAndMount, resolved.viewTransitions);
4084
+ * runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
4050
4085
  * ```
4051
4086
  */
4052
4087
  const renderAndMount = () => {
@@ -4054,7 +4089,7 @@ function createSpaKernel(state, config, emit, deps) {
4054
4089
  scanAndMount(state, emit, resolved.swapSelector);
4055
4090
  notifyNavEnd(state);
4056
4091
  };
4057
- runSwap(renderAndMount, resolved.viewTransitions);
4092
+ runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
4058
4093
  state.currentUrl = pathname;
4059
4094
  progress?.done();
4060
4095
  emit("spa:navigated", { url: pathname });
@@ -4091,11 +4126,14 @@ function createSpaKernel(state, config, emit, deps) {
4091
4126
  * navigation entry point (Navigation API, History, programmatic) goes through it.
4092
4127
  *
4093
4128
  * @param pathname - The destination pathname.
4129
+ * @param scrollToTop - Whether the swap should scroll to top before its snapshot
4130
+ * (default `true`; forward navs). Traverse passes `false` to keep its restored scroll.
4094
4131
  * @returns A promise resolving once the swap (or fallback) is dispatched.
4095
4132
  * @example
4096
4133
  * await navigate("/en/world/");
4097
4134
  */
4098
- const navigate = async (pathname) => {
4135
+ const navigate = async (pathname, scrollToTop = true) => {
4136
+ pendingScrollToTop = scrollToTop;
4099
4137
  if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
4100
4138
  await performNavigation(pathname, handlers);
4101
4139
  };
package/dist/index.cjs CHANGED
@@ -10112,15 +10112,28 @@ async function performNavigation(pathname, handlers) {
10112
10112
  * Run a DOM-mutating swap, optionally wrapped in the View Transitions API when
10113
10113
  * enabled and supported (instant swap otherwise — never throws).
10114
10114
  *
10115
+ * `beforeCapture` runs synchronously immediately before the swap — for the View
10116
+ * Transitions path that is before `startViewTransition` captures the "old" snapshot.
10117
+ * Scroll restoration belongs HERE, not in the router after the navigation: setting
10118
+ * the destination scroll at this moment means the old and new snapshots share the
10119
+ * same scrollY, so there is no scroll delta for the transition to animate and a
10120
+ * `position: sticky` header never un-pins (the cross-engine flicker, worst on WebKit).
10121
+ * Because the swap is post-fetch, doing it here also avoids the visible "scroll up,
10122
+ * THEN the page loads" pause that scrolling in the router (pre-fetch) produced.
10123
+ *
10115
10124
  * @param doSwap - The synchronous DOM mutation to perform.
10116
10125
  * @param viewTransitions - Whether to wrap the swap in `startViewTransition`.
10126
+ * @param beforeCapture - Optional hook run synchronously just before the swap/capture
10127
+ * (e.g. scroll to the destination position).
10117
10128
  * @example
10118
- * runSwap(() => current.replaceWith(next), true);
10129
+ * runSwap(() => current.replaceWith(next), true, () => scrollTo({ top: 0, behavior: "instant" }));
10119
10130
  */
10120
- function runSwap(doSwap, viewTransitions) {
10131
+ function runSwap(doSwap, viewTransitions, beforeCapture) {
10121
10132
  const reduced = typeof globalThis.matchMedia === "function" && globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches;
10122
10133
  const docWithVt = document;
10123
- if (viewTransitions && !reduced && typeof docWithVt.startViewTransition === "function") docWithVt.startViewTransition(doSwap);
10134
+ const canUseViewTransitions = viewTransitions && !reduced && typeof docWithVt.startViewTransition === "function";
10135
+ beforeCapture?.();
10136
+ if (canUseViewTransitions) docWithVt.startViewTransition(doSwap);
10124
10137
  else doSwap();
10125
10138
  }
10126
10139
  /**
@@ -10133,17 +10146,19 @@ function runSwap(doSwap, viewTransitions) {
10133
10146
  * @param swapSelector - CSS selector for the region to replace.
10134
10147
  * @param viewTransitions - Whether to wrap the swap in `startViewTransition`.
10135
10148
  * @param onSwapped - Callback run after the DOM mutation (mount/notify/scroll).
10149
+ * @param beforeCapture - Optional hook run synchronously just before the swap/capture
10150
+ * (forwarded to {@link runSwap} — e.g. scroll to the destination position).
10136
10151
  * @example
10137
10152
  * swapRegion(doc, "main > section", false, () => mountNew());
10138
10153
  */
10139
- function swapRegion(doc, swapSelector, viewTransitions, onSwapped) {
10154
+ function swapRegion(doc, swapSelector, viewTransitions, onSwapped, beforeCapture) {
10140
10155
  const newContent = doc.querySelector(swapSelector);
10141
10156
  const currentContent = document.querySelector(swapSelector);
10142
10157
  if (!newContent || !currentContent) return;
10143
10158
  runSwap(() => {
10144
10159
  currentContent.replaceWith(newContent);
10145
10160
  onSwapped();
10146
- }, viewTransitions);
10161
+ }, viewTransitions, beforeCapture);
10147
10162
  }
10148
10163
  /**
10149
10164
  * Resolve a navigable internal URL from a click event, or `undefined` when the
@@ -10198,7 +10213,7 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
10198
10213
  }
10199
10214
  saveScrollPosition(location.pathname);
10200
10215
  history.pushState({ scrollY: 0 }, "", url.pathname);
10201
- navigate(url.pathname).then(() => window.scrollTo(0, 0)).catch(() => {});
10216
+ navigate(url.pathname).catch(() => {});
10202
10217
  };
10203
10218
  /**
10204
10219
  * Re-run navigation on back/forward, restoring the saved scroll position.
@@ -10207,7 +10222,7 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
10207
10222
  * globalThis.addEventListener("popstate", onPopState);
10208
10223
  */
10209
10224
  const onPopState = () => {
10210
- navigate(location.pathname).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
10225
+ navigate(location.pathname, false).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
10211
10226
  };
10212
10227
  document.addEventListener("click", onClick);
10213
10228
  globalThis.addEventListener("popstate", onPopState);
@@ -10251,9 +10266,10 @@ function attachNavigationApi(navigation, handlers, navigate = (pathname) => perf
10251
10266
  navEvent.intercept({
10252
10267
  scroll: "manual",
10253
10268
  handler: async () => {
10254
- await navigate(url.pathname);
10255
- if (navEvent.navigationType === "traverse") navEvent.scroll();
10256
- else window.scrollTo(0, 0);
10269
+ if (navEvent.navigationType === "traverse") {
10270
+ await navigate(url.pathname, false);
10271
+ navEvent.scroll();
10272
+ } else await navigate(url.pathname);
10257
10273
  }
10258
10274
  });
10259
10275
  };
@@ -10412,6 +10428,25 @@ function syncDataHead(route, routeContext) {
10412
10428
  function createSpaKernel(state, config, emit, deps) {
10413
10429
  const resolved = resolveSpaConfig(config);
10414
10430
  let progress;
10431
+ let pendingScrollToTop = true;
10432
+ /**
10433
+ * Apply the in-flight navigation's scroll intent — the swap's `beforeCapture` hook.
10434
+ * For a forward nav it scrolls to top BEFORE the snapshot is captured, so the old and
10435
+ * new states share scrollY=0 (no delta → the sticky header never un-pins) and there is
10436
+ * no pre-fetch scroll pause. `behavior: "instant"` defeats a page-level
10437
+ * `scroll-behavior: smooth` that would otherwise animate the reset and re-create the
10438
+ * delta. Traverse (back/forward) sets `pendingScrollToTop = false` and restores its
10439
+ * saved position after the swap instead.
10440
+ *
10441
+ * @example
10442
+ * runSwap(renderAndMount, viewTransitions, applyPendingScroll);
10443
+ */
10444
+ const applyPendingScroll = () => {
10445
+ if (pendingScrollToTop) window.scrollTo({
10446
+ top: 0,
10447
+ behavior: "instant"
10448
+ });
10449
+ };
10415
10450
  /**
10416
10451
  * Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
10417
10452
  *
@@ -10427,7 +10462,7 @@ function createSpaKernel(state, config, emit, deps) {
10427
10462
  swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
10428
10463
  scanAndMount(state, emit, resolved.swapSelector);
10429
10464
  notifyNavEnd(state);
10430
- });
10465
+ }, applyPendingScroll);
10431
10466
  state.currentUrl = pathname;
10432
10467
  progress?.done();
10433
10468
  emit("spa:navigated", { url: pathname });
@@ -10522,7 +10557,7 @@ function createSpaKernel(state, config, emit, deps) {
10522
10557
  *
10523
10558
  * @example
10524
10559
  * ```ts
10525
- * runSwap(renderAndMount, resolved.viewTransitions);
10560
+ * runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
10526
10561
  * ```
10527
10562
  */
10528
10563
  const renderAndMount = () => {
@@ -10530,7 +10565,7 @@ function createSpaKernel(state, config, emit, deps) {
10530
10565
  scanAndMount(state, emit, resolved.swapSelector);
10531
10566
  notifyNavEnd(state);
10532
10567
  };
10533
- runSwap(renderAndMount, resolved.viewTransitions);
10568
+ runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
10534
10569
  state.currentUrl = pathname;
10535
10570
  progress?.done();
10536
10571
  emit("spa:navigated", { url: pathname });
@@ -10567,11 +10602,14 @@ function createSpaKernel(state, config, emit, deps) {
10567
10602
  * navigation entry point (Navigation API, History, programmatic) goes through it.
10568
10603
  *
10569
10604
  * @param pathname - The destination pathname.
10605
+ * @param scrollToTop - Whether the swap should scroll to top before its snapshot
10606
+ * (default `true`; forward navs). Traverse passes `false` to keep its restored scroll.
10570
10607
  * @returns A promise resolving once the swap (or fallback) is dispatched.
10571
10608
  * @example
10572
10609
  * await navigate("/en/world/");
10573
10610
  */
10574
- const navigate = async (pathname) => {
10611
+ const navigate = async (pathname, scrollToTop = true) => {
10612
+ pendingScrollToTop = scrollToTop;
10575
10613
  if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
10576
10614
  await performNavigation(pathname, handlers);
10577
10615
  };
package/dist/index.mjs CHANGED
@@ -10099,15 +10099,28 @@ async function performNavigation(pathname, handlers) {
10099
10099
  * Run a DOM-mutating swap, optionally wrapped in the View Transitions API when
10100
10100
  * enabled and supported (instant swap otherwise — never throws).
10101
10101
  *
10102
+ * `beforeCapture` runs synchronously immediately before the swap — for the View
10103
+ * Transitions path that is before `startViewTransition` captures the "old" snapshot.
10104
+ * Scroll restoration belongs HERE, not in the router after the navigation: setting
10105
+ * the destination scroll at this moment means the old and new snapshots share the
10106
+ * same scrollY, so there is no scroll delta for the transition to animate and a
10107
+ * `position: sticky` header never un-pins (the cross-engine flicker, worst on WebKit).
10108
+ * Because the swap is post-fetch, doing it here also avoids the visible "scroll up,
10109
+ * THEN the page loads" pause that scrolling in the router (pre-fetch) produced.
10110
+ *
10102
10111
  * @param doSwap - The synchronous DOM mutation to perform.
10103
10112
  * @param viewTransitions - Whether to wrap the swap in `startViewTransition`.
10113
+ * @param beforeCapture - Optional hook run synchronously just before the swap/capture
10114
+ * (e.g. scroll to the destination position).
10104
10115
  * @example
10105
- * runSwap(() => current.replaceWith(next), true);
10116
+ * runSwap(() => current.replaceWith(next), true, () => scrollTo({ top: 0, behavior: "instant" }));
10106
10117
  */
10107
- function runSwap(doSwap, viewTransitions) {
10118
+ function runSwap(doSwap, viewTransitions, beforeCapture) {
10108
10119
  const reduced = typeof globalThis.matchMedia === "function" && globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches;
10109
10120
  const docWithVt = document;
10110
- if (viewTransitions && !reduced && typeof docWithVt.startViewTransition === "function") docWithVt.startViewTransition(doSwap);
10121
+ const canUseViewTransitions = viewTransitions && !reduced && typeof docWithVt.startViewTransition === "function";
10122
+ beforeCapture?.();
10123
+ if (canUseViewTransitions) docWithVt.startViewTransition(doSwap);
10111
10124
  else doSwap();
10112
10125
  }
10113
10126
  /**
@@ -10120,17 +10133,19 @@ function runSwap(doSwap, viewTransitions) {
10120
10133
  * @param swapSelector - CSS selector for the region to replace.
10121
10134
  * @param viewTransitions - Whether to wrap the swap in `startViewTransition`.
10122
10135
  * @param onSwapped - Callback run after the DOM mutation (mount/notify/scroll).
10136
+ * @param beforeCapture - Optional hook run synchronously just before the swap/capture
10137
+ * (forwarded to {@link runSwap} — e.g. scroll to the destination position).
10123
10138
  * @example
10124
10139
  * swapRegion(doc, "main > section", false, () => mountNew());
10125
10140
  */
10126
- function swapRegion(doc, swapSelector, viewTransitions, onSwapped) {
10141
+ function swapRegion(doc, swapSelector, viewTransitions, onSwapped, beforeCapture) {
10127
10142
  const newContent = doc.querySelector(swapSelector);
10128
10143
  const currentContent = document.querySelector(swapSelector);
10129
10144
  if (!newContent || !currentContent) return;
10130
10145
  runSwap(() => {
10131
10146
  currentContent.replaceWith(newContent);
10132
10147
  onSwapped();
10133
- }, viewTransitions);
10148
+ }, viewTransitions, beforeCapture);
10134
10149
  }
10135
10150
  /**
10136
10151
  * Resolve a navigable internal URL from a click event, or `undefined` when the
@@ -10185,7 +10200,7 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
10185
10200
  }
10186
10201
  saveScrollPosition(location.pathname);
10187
10202
  history.pushState({ scrollY: 0 }, "", url.pathname);
10188
- navigate(url.pathname).then(() => window.scrollTo(0, 0)).catch(() => {});
10203
+ navigate(url.pathname).catch(() => {});
10189
10204
  };
10190
10205
  /**
10191
10206
  * Re-run navigation on back/forward, restoring the saved scroll position.
@@ -10194,7 +10209,7 @@ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigat
10194
10209
  * globalThis.addEventListener("popstate", onPopState);
10195
10210
  */
10196
10211
  const onPopState = () => {
10197
- navigate(location.pathname).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
10212
+ navigate(location.pathname, false).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
10198
10213
  };
10199
10214
  document.addEventListener("click", onClick);
10200
10215
  globalThis.addEventListener("popstate", onPopState);
@@ -10238,9 +10253,10 @@ function attachNavigationApi(navigation, handlers, navigate = (pathname) => perf
10238
10253
  navEvent.intercept({
10239
10254
  scroll: "manual",
10240
10255
  handler: async () => {
10241
- await navigate(url.pathname);
10242
- if (navEvent.navigationType === "traverse") navEvent.scroll();
10243
- else window.scrollTo(0, 0);
10256
+ if (navEvent.navigationType === "traverse") {
10257
+ await navigate(url.pathname, false);
10258
+ navEvent.scroll();
10259
+ } else await navigate(url.pathname);
10244
10260
  }
10245
10261
  });
10246
10262
  };
@@ -10399,6 +10415,25 @@ function syncDataHead(route, routeContext) {
10399
10415
  function createSpaKernel(state, config, emit, deps) {
10400
10416
  const resolved = resolveSpaConfig(config);
10401
10417
  let progress;
10418
+ let pendingScrollToTop = true;
10419
+ /**
10420
+ * Apply the in-flight navigation's scroll intent — the swap's `beforeCapture` hook.
10421
+ * For a forward nav it scrolls to top BEFORE the snapshot is captured, so the old and
10422
+ * new states share scrollY=0 (no delta → the sticky header never un-pins) and there is
10423
+ * no pre-fetch scroll pause. `behavior: "instant"` defeats a page-level
10424
+ * `scroll-behavior: smooth` that would otherwise animate the reset and re-create the
10425
+ * delta. Traverse (back/forward) sets `pendingScrollToTop = false` and restores its
10426
+ * saved position after the swap instead.
10427
+ *
10428
+ * @example
10429
+ * runSwap(renderAndMount, viewTransitions, applyPendingScroll);
10430
+ */
10431
+ const applyPendingScroll = () => {
10432
+ if (pendingScrollToTop) window.scrollTo({
10433
+ top: 0,
10434
+ behavior: "instant"
10435
+ });
10436
+ };
10402
10437
  /**
10403
10438
  * Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
10404
10439
  *
@@ -10414,7 +10449,7 @@ function createSpaKernel(state, config, emit, deps) {
10414
10449
  swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
10415
10450
  scanAndMount(state, emit, resolved.swapSelector);
10416
10451
  notifyNavEnd(state);
10417
- });
10452
+ }, applyPendingScroll);
10418
10453
  state.currentUrl = pathname;
10419
10454
  progress?.done();
10420
10455
  emit("spa:navigated", { url: pathname });
@@ -10509,7 +10544,7 @@ function createSpaKernel(state, config, emit, deps) {
10509
10544
  *
10510
10545
  * @example
10511
10546
  * ```ts
10512
- * runSwap(renderAndMount, resolved.viewTransitions);
10547
+ * runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
10513
10548
  * ```
10514
10549
  */
10515
10550
  const renderAndMount = () => {
@@ -10517,7 +10552,7 @@ function createSpaKernel(state, config, emit, deps) {
10517
10552
  scanAndMount(state, emit, resolved.swapSelector);
10518
10553
  notifyNavEnd(state);
10519
10554
  };
10520
- runSwap(renderAndMount, resolved.viewTransitions);
10555
+ runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
10521
10556
  state.currentUrl = pathname;
10522
10557
  progress?.done();
10523
10558
  emit("spa:navigated", { url: pathname });
@@ -10554,11 +10589,14 @@ function createSpaKernel(state, config, emit, deps) {
10554
10589
  * navigation entry point (Navigation API, History, programmatic) goes through it.
10555
10590
  *
10556
10591
  * @param pathname - The destination pathname.
10592
+ * @param scrollToTop - Whether the swap should scroll to top before its snapshot
10593
+ * (default `true`; forward navs). Traverse passes `false` to keep its restored scroll.
10557
10594
  * @returns A promise resolving once the swap (or fallback) is dispatched.
10558
10595
  * @example
10559
10596
  * await navigate("/en/world/");
10560
10597
  */
10561
- const navigate = async (pathname) => {
10598
+ const navigate = async (pathname, scrollToTop = true) => {
10599
+ pendingScrollToTop = scrollToTop;
10562
10600
  if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
10563
10601
  await performNavigation(pathname, handlers);
10564
10602
  };
package/package.json CHANGED
@@ -113,5 +113,5 @@
113
113
  "test:cli-e2e": "bun test src/plugins/cli/__tests__/e2e/",
114
114
  "test:coverage": "vitest run --project unit --project integration --coverage"
115
115
  },
116
- "version": "1.6.0"
116
+ "version": "1.6.1"
117
117
  }