@takazudo/zfb-runtime 2.6.0 → 2.7.0

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/README.md CHANGED
@@ -79,7 +79,7 @@ import type {
79
79
 
80
80
  ```ts
81
81
  import {
82
- ClientRouter, // framework-agnostic <head> helper — enables view-transition intercepts
82
+ ClientRouter, // framework-agnostic <head> helper — mount it to opt a page into SPA navigation
83
83
  navigate, // imperative navigation
84
84
  supportsViewTransitions, // browser capability check
85
85
  transitionEnabledOnThisPage, // reads zfb-view-transitions-enabled meta
@@ -104,6 +104,13 @@ import type { ContentSnapshot } from "@takazudo/zfb-runtime/snapshot";
104
104
  import { ClientRouter } from "@takazudo/zfb-runtime/client-router";
105
105
  ```
106
106
 
107
+ The two differ in one way that matters: the root barrel is **side-effect-free**
108
+ — importing anything from it, `ClientRouter` included, registers no listeners
109
+ and touches no history — while evaluating the `./client-router` subpath calls
110
+ `init()` and activates the router on the current page. See [Enabling SPA
111
+ soft-navigation](#enabling-spa-soft-navigation-the-runtime-ships-automatically)
112
+ below for how a normal zfb build gets that activation for you.
113
+
107
114
  #### Enabling SPA soft-navigation (the runtime ships automatically)
108
115
 
109
116
  Mounting `<ClientRouter />` in your layout `<head>` is normally **all you need**.
@@ -146,9 +153,15 @@ export default function Layout({ children }) {
146
153
  SPA navigation set so traversal behavior is deterministic.
147
154
 
148
155
  **How the runtime reaches the browser.** `<ClientRouter />` itself only renders
149
- SSR `<head>` tags. The click/form interception is registered by an `init()` call
150
- that runs as a side effect when `@takazudo/zfb-runtime/client-router` is imported
151
- in the browser, guarded so it never runs during SSR:
156
+ SSR `<head>` tags, and the module it lives in is pure: importing `ClientRouter`
157
+ from the `@takazudo/zfb-runtime` barrel registers nothing and runs no code on
158
+ the page. Everything the router does at startup seeding this page's history
159
+ entry and scroll position, marking the scripts the initial load already ran, and
160
+ registering the `popstate` / `load` / `pageshow` / scroll listeners plus the
161
+ click and form-submit intercepts — happens inside a single `init()` call.
162
+
163
+ `init()` is invoked as a side effect when `@takazudo/zfb-runtime/client-router`
164
+ is evaluated in the browser, guarded so it never runs during SSR:
152
165
 
153
166
  ```ts
154
167
  // inside @takazudo/zfb-runtime — client-router.ts
@@ -157,6 +170,9 @@ if (typeof document !== "undefined") {
157
170
  }
158
171
  ```
159
172
 
173
+ `init()` is idempotent: calling it again (a second `<ClientRouter />` mount, an
174
+ HMR re-run, a manual call) is a no-op for the parts already done.
175
+
160
176
  To get that side-effect import into the client bundle, zfb's island scanner
161
177
  detects when a page transitively reaches `<ClientRouter />` and injects
162
178
  `import "@takazudo/zfb-runtime/client-router"` into the islands asset
@@ -188,7 +204,10 @@ trigger — firing on them would ship the runtime to projects that only referenc
188
204
  `rt.ClientRouter`, and
189
205
  - a type-only import — `import type { ClientRouter }` (or `{ type ClientRouter }`).
190
206
 
191
- If soft-navigation is not working because your reference takes one of these forms
207
+ When detection misses, nothing else picks up the slack: the barrel import is
208
+ side-effect-free, so an undetected `<ClientRouter />` renders its meta tags into
209
+ `<head>` and no runtime is ever shipped to activate against them. If
210
+ soft-navigation is not working because your reference takes one of these forms
192
211
  (or the mounting module is otherwise not reachable from a page), force the
193
212
  runtime in with an explicit side-effect import from a page-reachable
194
213
  `"use client"` island. The island renders nothing; running its bundle in the
@@ -228,17 +247,29 @@ for the full API.
228
247
 
229
248
  #### `navigate()` needs `<ClientRouter />` mounted on the current page
230
249
 
231
- The root barrel exports `navigate` and `syncHistoryEntry`, but **not** `init`
232
- importing either of those two on their own is not enough to get soft
233
- navigation. `navigate()` checks for the `<meta
234
- name="zfb-view-transitions-enabled">` tag that `<ClientRouter />` renders into
235
- `<head>` before it will do a soft swap; with that tag absent it silently falls
236
- back to a full `location.href` load. Mount `<ClientRouter />` in the layout
237
- `<head>` of every page that should be soft-navigable — it both renders that
238
- meta tag and calls `init()` (click/form interception) as a side effect. `init`
239
- itself is exported from the `@takazudo/zfb-runtime/client-router` subpath, not
240
- the root barrel, for the rare case where you want to call it directly instead
241
- of mounting the component.
250
+ The root barrel exports `navigate` and `syncHistoryEntry`, but **not** `init`,
251
+ and importing from that barrel installs no interception listeners. Each helper
252
+ still does exactly what its own docs say when you call it — a direct
253
+ `navigate()` call navigates, `syncHistoryEntry()` writes its history entry —
254
+ but nothing starts intercepting the user's link clicks and form submits on
255
+ your behalf.
256
+
257
+ Soft navigation needs two separate things on the current page:
258
+
259
+ 1. **The opt-in meta tag.** `navigate()` reaches `<meta
260
+ name="zfb-view-transitions-enabled">`, which `<ClientRouter />` renders into
261
+ `<head>`, before it will do a soft swap; with that tag absent it falls back
262
+ to a full `location.href` load.
263
+ 2. **An activated router.** That comes from `init()` — in a normal zfb build,
264
+ from the `import "@takazudo/zfb-runtime/client-router"` the island scanner
265
+ injects once it sees a page reach `<ClientRouter />` (see above).
266
+
267
+ So the answer for both is the same: mount `<ClientRouter />` in the layout
268
+ `<head>` of every page that should be soft-navigable, and let the build ship
269
+ the runtime. `init` itself is exported from the
270
+ `@takazudo/zfb-runtime/client-router` subpath, not the root barrel, for the
271
+ rare case where you want to call it directly instead of relying on the
272
+ subpath's own import-time activation.
242
273
 
243
274
  #### Persisting elements and island state across navigations (`data-zfb-transition-persist`)
244
275
 
@@ -1,7 +1,11 @@
1
1
  // Public API surface for the client-router module.
2
2
  // This file is the barrel for @takazudo/zfb-runtime's client-router export.
3
3
  // W3D adds the <ClientRouter /> component and ClientRouterProps re-exports.
4
- // Component (W3D).
4
+ // Component (W3D). Re-exported via the activation shim `../client-router.js`
5
+ // (not `../client-router-component.js` directly) so importing this subpath
6
+ // barrel — the target of the islands bundler's auto-injected
7
+ // `import "@takazudo/zfb-runtime/client-router"` — keeps activating the
8
+ // router at module eval, byte-compatible with pre-split behavior (#2437).
5
9
  export { ClientRouter } from "../client-router.js";
6
10
  export { TRANSITION_BEFORE_PREPARATION, TRANSITION_AFTER_PREPARATION, TRANSITION_BEFORE_SWAP, TRANSITION_AFTER_SWAP, TRANSITION_PAGE_LOAD, TRANSITION_NAVIGATION_ABORTED, TransitionBeforePreparationEvent, TransitionBeforeSwapEvent, isTransitionBeforePreparationEvent, isTransitionBeforeSwapEvent, } from "./events.js";
7
11
  export { swapFunctions, swap } from "./swap-functions.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/client-router/index.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,4EAA4E;AAC5E,4EAA4E;AAE5E,mBAAmB;AACnB,OAAO,EAAE,YAAY,EAA0B,MAAM,qBAAqB,CAAC;AAE3E,OAAO,EACL,6BAA6B,EAC7B,4BAA4B,EAC5B,sBAAsB,EACtB,qBAAqB,EACrB,oBAAoB,EACpB,6BAA6B,EAC7B,gCAAgC,EAChC,yBAAyB,EACzB,kCAAkC,EAClC,2BAA2B,GAC5B,MAAM,aAAa,CAAC;AAUrB,OAAO,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAE1D,yBAAyB;AACzB,sEAAsE;AACtE,oDAAoD;AACpD,yFAAyF;AACzF,iFAAiF;AACjF,OAAO,EACL,QAAQ,EACR,uBAAuB,EACvB,2BAA2B,EAC3B,IAAI,EACJ,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAGrB,kCAAkC;AAClC,8EAA8E;AAC9E,kCAAkC;AAClC,OAAO,EAAE,QAAQ,EAAE,IAAI,IAAI,YAAY,EAAE,MAAM,eAAe,CAAC","sourcesContent":["// Public API surface for the client-router module.\n// This file is the barrel for @takazudo/zfb-runtime's client-router export.\n// W3D adds the <ClientRouter /> component and ClientRouterProps re-exports.\n\n// Component (W3D).\nexport { ClientRouter, type ClientRouterProps } from \"../client-router.js\";\n\nexport {\n TRANSITION_BEFORE_PREPARATION,\n TRANSITION_AFTER_PREPARATION,\n TRANSITION_BEFORE_SWAP,\n TRANSITION_AFTER_SWAP,\n TRANSITION_PAGE_LOAD,\n TRANSITION_NAVIGATION_ABORTED,\n TransitionBeforePreparationEvent,\n TransitionBeforeSwapEvent,\n isTransitionBeforePreparationEvent,\n isTransitionBeforeSwapEvent,\n} from \"./events.js\";\n\nexport type {\n Direction,\n Fallback,\n NavigationTypeString,\n Options,\n SyncHistoryEntryOptions,\n} from \"./types.js\";\n\nexport { swapFunctions, swap } from \"./swap-functions.js\";\n\n// Router public surface.\n// - W3C1: `supportsViewTransitions`, `transitionEnabledOnThisPage`.\n// - W3C2: `navigate()` (public navigation entry).\n// - W3C3: `init()` (idempotent bootstrap: registers click + form intercept listeners).\n// - W2 (#1377): `syncHistoryEntry()` (history bookkeeping without navigation).\nexport {\n navigate,\n supportsViewTransitions,\n transitionEnabledOnThisPage,\n init,\n syncHistoryEntry,\n} from \"./router.js\";\nexport type { InitOptions } from \"./router.js\";\n\n// Prefetch public surface (#276).\n// `init` from prefetch.ts is re-exported as `prefetchInit` to avoid colliding\n// with the router's `init` above.\nexport { prefetch, init as prefetchInit } from \"./prefetch.js\";\nexport type { PrefetchStrategy, PrefetchInitOptions, PrefetchOptions } from \"./prefetch.js\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/client-router/index.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,4EAA4E;AAC5E,4EAA4E;AAE5E,6EAA6E;AAC7E,2EAA2E;AAC3E,6DAA6D;AAC7D,wEAAwE;AACxE,0EAA0E;AAC1E,OAAO,EAAE,YAAY,EAA0B,MAAM,qBAAqB,CAAC;AAE3E,OAAO,EACL,6BAA6B,EAC7B,4BAA4B,EAC5B,sBAAsB,EACtB,qBAAqB,EACrB,oBAAoB,EACpB,6BAA6B,EAC7B,gCAAgC,EAChC,yBAAyB,EACzB,kCAAkC,EAClC,2BAA2B,GAC5B,MAAM,aAAa,CAAC;AAUrB,OAAO,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAE1D,yBAAyB;AACzB,sEAAsE;AACtE,oDAAoD;AACpD,yFAAyF;AACzF,iFAAiF;AACjF,OAAO,EACL,QAAQ,EACR,uBAAuB,EACvB,2BAA2B,EAC3B,IAAI,EACJ,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAGrB,kCAAkC;AAClC,8EAA8E;AAC9E,kCAAkC;AAClC,OAAO,EAAE,QAAQ,EAAE,IAAI,IAAI,YAAY,EAAE,MAAM,eAAe,CAAC","sourcesContent":["// Public API surface for the client-router module.\n// This file is the barrel for @takazudo/zfb-runtime's client-router export.\n// W3D adds the <ClientRouter /> component and ClientRouterProps re-exports.\n\n// Component (W3D). Re-exported via the activation shim `../client-router.js`\n// (not `../client-router-component.js` directly) so importing this subpath\n// barrel — the target of the islands bundler's auto-injected\n// `import \"@takazudo/zfb-runtime/client-router\"` — keeps activating the\n// router at module eval, byte-compatible with pre-split behavior (#2437).\nexport { ClientRouter, type ClientRouterProps } from \"../client-router.js\";\n\nexport {\n TRANSITION_BEFORE_PREPARATION,\n TRANSITION_AFTER_PREPARATION,\n TRANSITION_BEFORE_SWAP,\n TRANSITION_AFTER_SWAP,\n TRANSITION_PAGE_LOAD,\n TRANSITION_NAVIGATION_ABORTED,\n TransitionBeforePreparationEvent,\n TransitionBeforeSwapEvent,\n isTransitionBeforePreparationEvent,\n isTransitionBeforeSwapEvent,\n} from \"./events.js\";\n\nexport type {\n Direction,\n Fallback,\n NavigationTypeString,\n Options,\n SyncHistoryEntryOptions,\n} from \"./types.js\";\n\nexport { swapFunctions, swap } from \"./swap-functions.js\";\n\n// Router public surface.\n// - W3C1: `supportsViewTransitions`, `transitionEnabledOnThisPage`.\n// - W3C2: `navigate()` (public navigation entry).\n// - W3C3: `init()` (idempotent bootstrap: registers click + form intercept listeners).\n// - W2 (#1377): `syncHistoryEntry()` (history bookkeeping without navigation).\nexport {\n navigate,\n supportsViewTransitions,\n transitionEnabledOnThisPage,\n init,\n syncHistoryEntry,\n} from \"./router.js\";\nexport type { InitOptions } from \"./router.js\";\n\n// Prefetch public surface (#276).\n// `init` from prefetch.ts is re-exported as `prefetchInit` to avoid colliding\n// with the router's `init` above.\nexport { prefetch, init as prefetchInit } from \"./prefetch.js\";\nexport type { PrefetchStrategy, PrefetchInitOptions, PrefetchOptions } from \"./prefetch.js\";\n"]}
@@ -35,8 +35,14 @@ export interface InitOptions {
35
35
  prefetchAll?: boolean;
36
36
  }
37
37
  /**
38
- * Wire up the client-router's click and form-submit intercepts.
39
- * Safe to call multiple times subsequent calls are no-ops (idempotent).
38
+ * Bootstrap the client router on this page: restore its history entry, mark
39
+ * the already-executed scripts, and wire up the navigation, click and
40
+ * form-submit listeners. Safe to call multiple times — each of the two phases
41
+ * runs at most once (idempotent).
42
+ *
43
+ * Evaluating this module does nothing; every side effect the router performs
44
+ * at startup happens here (#2436). Two once-guards rather than one, because
45
+ * the phases have different eligibility — see `pageStateSeeded`.
40
46
  *
41
47
  * @param _options - Forward-compat hook matching Astro's init() signature. Ignored in v1.
42
48
  */
@@ -30,9 +30,11 @@
30
30
  // W3C2 additions (this file):
31
31
  // - `navigate()` public entry.
32
32
  // - `onPopState`, `onScrollEnd`.
33
- // - Top-level `if (inBrowser)` initialization block (seeds `currentHistoryIndex`
34
- // from `history.state`, registers popstate / load / scrollend listeners, and
35
- // marks already-executed scripts with `dataset["zfbExec"] = ""`).
33
+ // - Seeding `currentHistoryIndex` from `history.state`, registering popstate /
34
+ // load / scrollend listeners, and marking already-executed scripts with
35
+ // `dataset["zfbExec"] = ""`. W3C2 did all of this in two bare top-level
36
+ // `if (inBrowser)` blocks; #2436 folded both into `init()` (see the two
37
+ // once-guards there) so evaluating this module has no side effect at all.
36
38
  //
37
39
  // W3C1 items deferred to — and since implemented in — W3C3:
38
40
  // - `announce()` route-announcer implementation (see the route-announcer block below;
@@ -146,19 +148,31 @@ let parser;
146
148
  // you can figure it using an index. On pushState the index is incremented so you
147
149
  // can use that to determine popstate if going forward or back.
148
150
  let currentHistoryIndex = 0;
149
- if (inBrowser) {
150
- if (history.state) {
151
- // Here we reloaded a page with history state
152
- // (e.g. history navigation from non-transition page or browser reload)
153
- currentHistoryIndex = history.state.index;
154
- scrollTo({ left: history.state.scrollX, top: history.state.scrollY });
155
- }
156
- else if (transitionEnabledOnThisPage()) {
157
- // This page is loaded from the browser address bar or via a link from extern,
158
- // it needs a state in the history
159
- safeReplaceState({ index: currentHistoryIndex, scrollX, scrollY }, "");
160
- history.scrollRestoration = "manual";
161
- }
151
+ // Once-guard for the navigation bookkeeping below. `init()` sets it too, so a
152
+ // later ensureNavigationState() can never clobber the activation-time seed.
153
+ let navigationStateSeeded = false;
154
+ /**
155
+ * Seed the module's navigation bookkeeping — `originalLocation` (declared
156
+ * without an initializer) and the tracked history index — from the live
157
+ * document, once.
158
+ *
159
+ * Deliberately side-effect free beyond those two variables: it registers no
160
+ * listener, writes no history entry, never scrolls, and never marks scripts.
161
+ * All of that belongs to {@link init}. Its only job is to keep an init-less
162
+ * `navigate()` / `syncHistoryEntry()` from reading `undefined` or stamping a
163
+ * fresh entry with the module default index — using the router without
164
+ * activating it is documented as unsupported, but it must not throw and must
165
+ * not mis-stamp. An existing finite `history.state.index` is ADOPTED, never
166
+ * reset: on a page whose entry is already index 7, the next push must be 8. (#2436)
167
+ */
168
+ function ensureNavigationState() {
169
+ if (navigationStateSeeded || !inBrowser)
170
+ return;
171
+ navigationStateSeeded = true;
172
+ originalLocation = new URL(location.href);
173
+ const index = history.state?.index;
174
+ if (Number.isFinite(index))
175
+ currentHistoryIndex = index;
162
176
  }
163
177
  // returns the contents of the page or null if the router can't deal with it.
164
178
  async function fetchHTML(href, init) {
@@ -369,6 +383,9 @@ export function syncHistoryEntry(url, options = {}) {
369
383
  }
370
384
  return;
371
385
  }
386
+ // Adopt this page's existing history index before stamping an entry — an
387
+ // init-less caller must not reset a live entry's index to the module default.
388
+ ensureNavigationState();
372
389
  const to = new URL(url, location.href);
373
390
  // Cross-origin is not ours to manage. The History API would throw a native
374
391
  // SecurityError for a cross-origin URL, but we guard explicitly so the failure
@@ -810,6 +827,9 @@ export async function navigate(href, options) {
810
827
  }
811
828
  return;
812
829
  }
830
+ // `originalLocation` is seeded by init(); seed it here too so an init-less
831
+ // caller never hands transition() an undefined "from" URL.
832
+ ensureNavigationState();
813
833
  await transition("forward", originalLocation, new URL(href, location.href), options ?? {});
814
834
  }
815
835
  function onPopState(ev) {
@@ -864,22 +884,22 @@ const onScrollEnd = () => {
864
884
  };
865
885
  // zfb-only addition (no Astro upstream — see file header "zfb-only additions").
866
886
  // WebKit serves Back navigations after an SPA route change from the bfcache:
867
- // the page is restored without re-evaluating this module, so the init-block
868
- // seed below (which sets `currentHistoryIndex` from `history.state.index`)
869
- // never re-runs and the tracked index can desync from the live history stack.
870
- // A desynced index makes onPopState's direction calc misfire, so Back skips an
871
- // entry. On a persisted (bfcache) restore we re-seed the tracked index and the
872
- // `originalLocation` "from" URL from the live state, and restore scroll —
873
- // mirroring the init-block seed. This is a no-op on a normal load (`persisted`
874
- // falsy/absent) and idempotent (re-seeding from the same state twice changes
875
- // nothing and fires no transition).
887
+ // the page is restored without re-evaluating this module or re-running init(),
888
+ // so `seedPageState()` below (which sets `currentHistoryIndex` from
889
+ // `history.state.index`) never re-runs and the tracked index can desync from
890
+ // the live history stack. A desynced index makes onPopState's direction calc
891
+ // misfire, so Back skips an entry. On a persisted (bfcache) restore we re-seed
892
+ // the tracked index and the `originalLocation` "from" URL from the live state,
893
+ // and restore scroll — mirroring that seed. This is a no-op on a normal load
894
+ // (`persisted` falsy/absent) and idempotent (re-seeding from the same state
895
+ // twice changes nothing and fires no transition).
876
896
  const onPageShow = (ev) => {
877
- // Normal (non-bfcache) loads already ran the init-block seed; leave them be.
897
+ // Normal (non-bfcache) loads already ran init()'s seed; leave them be.
878
898
  if (!ev.persisted)
879
899
  return;
880
- // The init block sets `originalLocation` and `currentHistoryIndex` together;
881
- // re-sync both here so the next transition() gets the correct `from` URL
882
- // (a stale `originalLocation` would feed onPopState the wrong origin).
900
+ // init() seeds `originalLocation` and `currentHistoryIndex` together; re-sync
901
+ // both here so the next transition() gets the correct `from` URL (a stale
902
+ // `originalLocation` would feed onPopState the wrong origin).
883
903
  originalLocation = new URL(location.href);
884
904
  const index = history.state?.index;
885
905
  if (Number.isFinite(index)) {
@@ -889,67 +909,116 @@ const onPageShow = (ev) => {
889
909
  scrollTo({ left: history.state.scrollX, top: history.state.scrollY });
890
910
  }
891
911
  };
892
- // initialization
893
- if (inBrowser) {
894
- if (supportsViewTransitions || getFallback() !== "none") {
895
- originalLocation = new URL(location.href);
896
- addEventListener("popstate", onPopState);
897
- addEventListener("load", onPageLoad);
898
- // Re-sync the tracked history index + scroll on a WebKit bfcache restore;
899
- // a no-op on normal loads. See onPageShow above.
900
- addEventListener("pageshow", onPageShow);
901
- // There's not a good way to record scroll position before a history back
902
- // navigation, so we will record it when the user has stopped scrolling.
903
- if ("onscrollend" in window)
904
- addEventListener("scrollend", onScrollEnd);
905
- else {
906
- // Keep track of state between intervals
907
- let intervalId, lastY, lastX, lastIndex;
908
- const scrollInterval = () => {
909
- // The interval can outlive its document context a test-env teardown or a
910
- // real page unload removes window/history before the 50ms tick. `typeof`
911
- // never throws even when the global is gone: stop the interval and bail
912
- // rather than dereferencing a torn-down global (the #1061 bug class; #1063).
913
- // In a real browser these globals always exist, so this path is test-env
914
- // only. clearInterval may itself be mid-teardown, so guard the call too.
915
- if (typeof window === "undefined" || typeof history === "undefined") {
916
- if (typeof clearInterval === "function")
917
- clearInterval(intervalId);
918
- intervalId = undefined;
919
- return;
920
- }
921
- // Check the index to see if a popstate event was fired
922
- if (lastIndex !== history.state?.index) {
923
- clearInterval(intervalId);
924
- intervalId = undefined;
925
- return;
926
- }
927
- // Check if the user stopped scrolling
928
- if (lastY === scrollY && lastX === scrollX) {
929
- // Cancel the interval and update scroll positions
930
- clearInterval(intervalId);
931
- intervalId = undefined;
932
- onScrollEnd();
933
- return;
934
- }
935
- else {
936
- ((lastY = scrollY), (lastX = scrollX));
937
- }
938
- };
939
- // We can't know when or how often scroll events fire, so we'll just use them to start intervals
940
- addEventListener("scroll", () => {
941
- if (intervalId !== undefined)
942
- return;
943
- ((lastIndex = history.state?.index), (lastY = scrollY), (lastX = scrollX));
944
- intervalId = window.setInterval(scrollInterval, 50);
945
- }, { passive: true });
912
+ // ---- initialization (folded out of module scope by #2436) ----
913
+ // Once-guard for the page-state phase of init(). Deliberately SEPARATE from the
914
+ // `initialized` latch below, because the two phases have different eligibility:
915
+ // this one runs on EVERY browser page — including one where view transitions
916
+ // are ineligible (no native support and fallback "none") — while the activation
917
+ // listeners are VT-gated. A single shared latch would either skip this phase
918
+ // forever after an ineligible early call, or latch activation on a page that
919
+ // never qualified for it. (#2436)
920
+ let pageStateSeeded = false;
921
+ /**
922
+ * Everything module evaluation used to do for EVERY browser page, regardless
923
+ * of view-transition eligibility: restore (or seed) this page's history entry
924
+ * and its scroll position, and mark the scripts the initial page load already
925
+ * executed so runScripts() does not re-run them after a swap.
926
+ *
927
+ * Called from init() BEFORE its eligibility early-return — that gating
928
+ * asymmetry is load-bearing and predates the fold: a page with fallback "none"
929
+ * still needs its history entry restored and its scripts marked. (#2436)
930
+ */
931
+ function seedPageState() {
932
+ if (pageStateSeeded)
933
+ return;
934
+ pageStateSeeded = true;
935
+ if (history.state) {
936
+ // Here we reloaded a page with history state
937
+ // (e.g. history navigation from non-transition page or browser reload)
938
+ // Adopt only a finite index — a foreign pre-init history.state (e.g. a
939
+ // consumer's own replaceState({modal: true})) has no router index, and
940
+ // adopting undefined would stamp the next push with NaN. Same guard as
941
+ // ensureNavigationState() and onPageShow(). (#2436)
942
+ const index = history.state.index;
943
+ if (Number.isFinite(index))
944
+ currentHistoryIndex = index;
945
+ // Skip the scroll restore when an init-less navigate()/syncHistoryEntry()
946
+ // already ran on this page (navigationStateSeeded latched before init):
947
+ // the entry it pushed is stamped scrollX/scrollY 0, and a late-activating
948
+ // init() must not yank the already-scrolled viewport to the top. (#2436)
949
+ if (!navigationStateSeeded) {
950
+ scrollTo({ left: history.state.scrollX, top: history.state.scrollY });
946
951
  }
947
952
  }
953
+ else if (transitionEnabledOnThisPage()) {
954
+ // This page is loaded from the browser address bar or via a link from extern,
955
+ // it needs a state in the history
956
+ safeReplaceState({ index: currentHistoryIndex, scrollX, scrollY }, "");
957
+ history.scrollRestoration = "manual";
958
+ }
948
959
  for (const script of document.getElementsByTagName("script")) {
949
960
  detectScriptExecuted(script);
950
961
  script.dataset["zfbExec"] = "";
951
962
  }
952
963
  }
964
+ /**
965
+ * Register the window-level navigation listeners. Called from init() only
966
+ * after the view-transition eligibility check, and only once — the
967
+ * `initialized` latch owns this phase.
968
+ */
969
+ function registerNavigationListeners() {
970
+ addEventListener("popstate", onPopState);
971
+ addEventListener("load", onPageLoad);
972
+ // Re-sync the tracked history index + scroll on a WebKit bfcache restore;
973
+ // a no-op on normal loads. See onPageShow above.
974
+ addEventListener("pageshow", onPageShow);
975
+ // There's not a good way to record scroll position before a history back
976
+ // navigation, so we will record it when the user has stopped scrolling.
977
+ if ("onscrollend" in window)
978
+ addEventListener("scrollend", onScrollEnd);
979
+ else {
980
+ // Keep track of state between intervals
981
+ let intervalId, lastY, lastX, lastIndex;
982
+ const scrollInterval = () => {
983
+ // The interval can outlive its document context — a test-env teardown or a
984
+ // real page unload removes window/history before the 50ms tick. `typeof`
985
+ // never throws even when the global is gone: stop the interval and bail
986
+ // rather than dereferencing a torn-down global (the #1061 bug class; #1063).
987
+ // In a real browser these globals always exist, so this path is test-env
988
+ // only. clearInterval may itself be mid-teardown, so guard the call too.
989
+ if (typeof window === "undefined" || typeof history === "undefined") {
990
+ if (typeof clearInterval === "function")
991
+ clearInterval(intervalId);
992
+ intervalId = undefined;
993
+ return;
994
+ }
995
+ // Check the index to see if a popstate event was fired
996
+ if (lastIndex !== history.state?.index) {
997
+ clearInterval(intervalId);
998
+ intervalId = undefined;
999
+ return;
1000
+ }
1001
+ // Check if the user stopped scrolling
1002
+ if (lastY === scrollY && lastX === scrollX) {
1003
+ // Cancel the interval and update scroll positions
1004
+ clearInterval(intervalId);
1005
+ intervalId = undefined;
1006
+ onScrollEnd();
1007
+ return;
1008
+ }
1009
+ else {
1010
+ ((lastY = scrollY), (lastX = scrollX));
1011
+ }
1012
+ };
1013
+ // We can't know when or how often scroll events fire, so we'll just use them to start intervals
1014
+ addEventListener("scroll", () => {
1015
+ if (intervalId !== undefined)
1016
+ return;
1017
+ ((lastIndex = history.state?.index), (lastY = scrollY), (lastX = scrollX));
1018
+ intervalId = window.setInterval(scrollInterval, 50);
1019
+ }, { passive: true });
1020
+ }
1021
+ }
953
1022
  // ---- W3C3: click + form intercept, public idempotent init() ----
954
1023
  // Returns true when the modifier-key combo or mouse button means "open in new tab / download".
955
1024
  // Matches Astro's `leavesWindow` helper in ClientRouter.astro.
@@ -1046,26 +1115,44 @@ function handleSubmit(ev) {
1046
1115
  ev.preventDefault();
1047
1116
  navigate(action, options);
1048
1117
  }
1049
- // Guard flag — ensures click + submit listeners are registered only once even if
1050
- // init() is called multiple times (e.g. two <ClientRouter> mounts on the same page).
1118
+ // Guard flag — ensures the activation listeners (popstate/load/pageshow/scroll
1119
+ // plus click + submit) are registered only once even if init() is called
1120
+ // multiple times (e.g. two <ClientRouter> mounts on the same page).
1051
1121
  let initialized = false;
1052
1122
  /**
1053
- * Wire up the client-router's click and form-submit intercepts.
1054
- * Safe to call multiple times subsequent calls are no-ops (idempotent).
1123
+ * Bootstrap the client router on this page: restore its history entry, mark
1124
+ * the already-executed scripts, and wire up the navigation, click and
1125
+ * form-submit listeners. Safe to call multiple times — each of the two phases
1126
+ * runs at most once (idempotent).
1127
+ *
1128
+ * Evaluating this module does nothing; every side effect the router performs
1129
+ * at startup happens here (#2436). Two once-guards rather than one, because
1130
+ * the phases have different eligibility — see `pageStateSeeded`.
1055
1131
  *
1056
1132
  * @param _options - Forward-compat hook matching Astro's init() signature. Ignored in v1.
1057
1133
  */
1058
1134
  export function init(_options) {
1059
- if (initialized)
1060
- return;
1061
- // Latch the guard only AFTER the early-return guards below: an ineligible
1062
- // early call (no browser, or fallback "none") must not permanently latch
1063
- // `initialized`, or a later legitimately-eligible init() would no-op forever.
1064
1135
  if (!inBrowser)
1065
1136
  return;
1137
+ // Phase 1 — runs on every browser page, even a view-transition-ineligible
1138
+ // one, and therefore BEFORE the eligibility early-return below.
1139
+ seedPageState();
1140
+ if (initialized)
1141
+ return;
1142
+ // Phase 2 — activation. Latch the guard only AFTER this eligibility check:
1143
+ // an ineligible early call (fallback "none" and no native view transitions)
1144
+ // must not permanently latch `initialized`, or a later legitimately-eligible
1145
+ // init() would no-op forever.
1066
1146
  if (!supportsViewTransitions && getFallback() === "none")
1067
1147
  return;
1068
1148
  initialized = true;
1149
+ // Where we came from, for the first transition() of this page. Re-seeded
1150
+ // unconditionally at activation (as module evaluation used to do), so it
1151
+ // reflects the URL at activation time even if an init-less navigate() or
1152
+ // syncHistoryEntry() already primed it via ensureNavigationState().
1153
+ navigationStateSeeded = true;
1154
+ originalLocation = new URL(location.href);
1155
+ registerNavigationListeners();
1069
1156
  document.addEventListener("click", handleClick);
1070
1157
  document.addEventListener("submit", handleSubmit);
1071
1158
  // Prefetch hook intentionally omitted from v1 — see https://github.com/zudolab/zudo-doc/issues/1527