@moku-labs/web 1.6.0 → 1.6.2

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,31 @@ 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. Traverse (back/forward) sets `pendingScrollToTop = false`
3961
+ * and restores its saved position after the swap instead.
3962
+ *
3963
+ * Scroll behaviour: `"instant"` ONLY when view transitions are enabled — that is what
3964
+ * keeps scrollY=0 in the captured snapshot (a `scroll-behavior: smooth` would otherwise
3965
+ * animate the reset and re-create the delta → sticky-header flicker). With view
3966
+ * transitions OFF there is no snapshot to protect, so it honours the page's
3967
+ * `scroll-behavior` (`"auto"` = use the CSS value, e.g. a smooth scroll-to-top on nav).
3968
+ *
3969
+ * @example
3970
+ * runSwap(renderAndMount, viewTransitions, applyPendingScroll);
3971
+ */
3972
+ const applyPendingScroll = () => {
3973
+ if (!pendingScrollToTop) return;
3974
+ const behavior = resolved.viewTransitions ? "instant" : "auto";
3975
+ window.scrollTo({
3976
+ top: 0,
3977
+ behavior
3978
+ });
3979
+ };
3939
3980
  /**
3940
3981
  * Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
3941
3982
  *
@@ -3951,7 +3992,7 @@ function createSpaKernel(state, config, emit, deps) {
3951
3992
  swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
3952
3993
  scanAndMount(state, emit, resolved.swapSelector);
3953
3994
  notifyNavEnd(state);
3954
- });
3995
+ }, applyPendingScroll);
3955
3996
  state.currentUrl = pathname;
3956
3997
  progress?.done();
3957
3998
  emit("spa:navigated", { url: pathname });
@@ -4046,7 +4087,7 @@ function createSpaKernel(state, config, emit, deps) {
4046
4087
  *
4047
4088
  * @example
4048
4089
  * ```ts
4049
- * runSwap(renderAndMount, resolved.viewTransitions);
4090
+ * runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
4050
4091
  * ```
4051
4092
  */
4052
4093
  const renderAndMount = () => {
@@ -4054,7 +4095,7 @@ function createSpaKernel(state, config, emit, deps) {
4054
4095
  scanAndMount(state, emit, resolved.swapSelector);
4055
4096
  notifyNavEnd(state);
4056
4097
  };
4057
- runSwap(renderAndMount, resolved.viewTransitions);
4098
+ runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
4058
4099
  state.currentUrl = pathname;
4059
4100
  progress?.done();
4060
4101
  emit("spa:navigated", { url: pathname });
@@ -4091,11 +4132,14 @@ function createSpaKernel(state, config, emit, deps) {
4091
4132
  * navigation entry point (Navigation API, History, programmatic) goes through it.
4092
4133
  *
4093
4134
  * @param pathname - The destination pathname.
4135
+ * @param scrollToTop - Whether the swap should scroll to top before its snapshot
4136
+ * (default `true`; forward navs). Traverse passes `false` to keep its restored scroll.
4094
4137
  * @returns A promise resolving once the swap (or fallback) is dispatched.
4095
4138
  * @example
4096
4139
  * await navigate("/en/world/");
4097
4140
  */
4098
- const navigate = async (pathname) => {
4141
+ const navigate = async (pathname, scrollToTop = true) => {
4142
+ pendingScrollToTop = scrollToTop;
4099
4143
  if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
4100
4144
  await performNavigation(pathname, handlers);
4101
4145
  };
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,31 @@ 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. Traverse (back/forward) sets `pendingScrollToTop = false`
10437
+ * and restores its saved position after the swap instead.
10438
+ *
10439
+ * Scroll behaviour: `"instant"` ONLY when view transitions are enabled — that is what
10440
+ * keeps scrollY=0 in the captured snapshot (a `scroll-behavior: smooth` would otherwise
10441
+ * animate the reset and re-create the delta → sticky-header flicker). With view
10442
+ * transitions OFF there is no snapshot to protect, so it honours the page's
10443
+ * `scroll-behavior` (`"auto"` = use the CSS value, e.g. a smooth scroll-to-top on nav).
10444
+ *
10445
+ * @example
10446
+ * runSwap(renderAndMount, viewTransitions, applyPendingScroll);
10447
+ */
10448
+ const applyPendingScroll = () => {
10449
+ if (!pendingScrollToTop) return;
10450
+ const behavior = resolved.viewTransitions ? "instant" : "auto";
10451
+ window.scrollTo({
10452
+ top: 0,
10453
+ behavior
10454
+ });
10455
+ };
10415
10456
  /**
10416
10457
  * Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
10417
10458
  *
@@ -10427,7 +10468,7 @@ function createSpaKernel(state, config, emit, deps) {
10427
10468
  swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
10428
10469
  scanAndMount(state, emit, resolved.swapSelector);
10429
10470
  notifyNavEnd(state);
10430
- });
10471
+ }, applyPendingScroll);
10431
10472
  state.currentUrl = pathname;
10432
10473
  progress?.done();
10433
10474
  emit("spa:navigated", { url: pathname });
@@ -10522,7 +10563,7 @@ function createSpaKernel(state, config, emit, deps) {
10522
10563
  *
10523
10564
  * @example
10524
10565
  * ```ts
10525
- * runSwap(renderAndMount, resolved.viewTransitions);
10566
+ * runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
10526
10567
  * ```
10527
10568
  */
10528
10569
  const renderAndMount = () => {
@@ -10530,7 +10571,7 @@ function createSpaKernel(state, config, emit, deps) {
10530
10571
  scanAndMount(state, emit, resolved.swapSelector);
10531
10572
  notifyNavEnd(state);
10532
10573
  };
10533
- runSwap(renderAndMount, resolved.viewTransitions);
10574
+ runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
10534
10575
  state.currentUrl = pathname;
10535
10576
  progress?.done();
10536
10577
  emit("spa:navigated", { url: pathname });
@@ -10567,11 +10608,14 @@ function createSpaKernel(state, config, emit, deps) {
10567
10608
  * navigation entry point (Navigation API, History, programmatic) goes through it.
10568
10609
  *
10569
10610
  * @param pathname - The destination pathname.
10611
+ * @param scrollToTop - Whether the swap should scroll to top before its snapshot
10612
+ * (default `true`; forward navs). Traverse passes `false` to keep its restored scroll.
10570
10613
  * @returns A promise resolving once the swap (or fallback) is dispatched.
10571
10614
  * @example
10572
10615
  * await navigate("/en/world/");
10573
10616
  */
10574
- const navigate = async (pathname) => {
10617
+ const navigate = async (pathname, scrollToTop = true) => {
10618
+ pendingScrollToTop = scrollToTop;
10575
10619
  if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
10576
10620
  await performNavigation(pathname, handlers);
10577
10621
  };
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,31 @@ 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. Traverse (back/forward) sets `pendingScrollToTop = false`
10424
+ * and restores its saved position after the swap instead.
10425
+ *
10426
+ * Scroll behaviour: `"instant"` ONLY when view transitions are enabled — that is what
10427
+ * keeps scrollY=0 in the captured snapshot (a `scroll-behavior: smooth` would otherwise
10428
+ * animate the reset and re-create the delta → sticky-header flicker). With view
10429
+ * transitions OFF there is no snapshot to protect, so it honours the page's
10430
+ * `scroll-behavior` (`"auto"` = use the CSS value, e.g. a smooth scroll-to-top on nav).
10431
+ *
10432
+ * @example
10433
+ * runSwap(renderAndMount, viewTransitions, applyPendingScroll);
10434
+ */
10435
+ const applyPendingScroll = () => {
10436
+ if (!pendingScrollToTop) return;
10437
+ const behavior = resolved.viewTransitions ? "instant" : "auto";
10438
+ window.scrollTo({
10439
+ top: 0,
10440
+ behavior
10441
+ });
10442
+ };
10402
10443
  /**
10403
10444
  * Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
10404
10445
  *
@@ -10414,7 +10455,7 @@ function createSpaKernel(state, config, emit, deps) {
10414
10455
  swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
10415
10456
  scanAndMount(state, emit, resolved.swapSelector);
10416
10457
  notifyNavEnd(state);
10417
- });
10458
+ }, applyPendingScroll);
10418
10459
  state.currentUrl = pathname;
10419
10460
  progress?.done();
10420
10461
  emit("spa:navigated", { url: pathname });
@@ -10509,7 +10550,7 @@ function createSpaKernel(state, config, emit, deps) {
10509
10550
  *
10510
10551
  * @example
10511
10552
  * ```ts
10512
- * runSwap(renderAndMount, resolved.viewTransitions);
10553
+ * runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
10513
10554
  * ```
10514
10555
  */
10515
10556
  const renderAndMount = () => {
@@ -10517,7 +10558,7 @@ function createSpaKernel(state, config, emit, deps) {
10517
10558
  scanAndMount(state, emit, resolved.swapSelector);
10518
10559
  notifyNavEnd(state);
10519
10560
  };
10520
- runSwap(renderAndMount, resolved.viewTransitions);
10561
+ runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
10521
10562
  state.currentUrl = pathname;
10522
10563
  progress?.done();
10523
10564
  emit("spa:navigated", { url: pathname });
@@ -10554,11 +10595,14 @@ function createSpaKernel(state, config, emit, deps) {
10554
10595
  * navigation entry point (Navigation API, History, programmatic) goes through it.
10555
10596
  *
10556
10597
  * @param pathname - The destination pathname.
10598
+ * @param scrollToTop - Whether the swap should scroll to top before its snapshot
10599
+ * (default `true`; forward navs). Traverse passes `false` to keep its restored scroll.
10557
10600
  * @returns A promise resolving once the swap (or fallback) is dispatched.
10558
10601
  * @example
10559
10602
  * await navigate("/en/world/");
10560
10603
  */
10561
- const navigate = async (pathname) => {
10604
+ const navigate = async (pathname, scrollToTop = true) => {
10605
+ pendingScrollToTop = scrollToTop;
10562
10606
  if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
10563
10607
  await performNavigation(pathname, handlers);
10564
10608
  };
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.2"
117
117
  }