@takazudo/zfb-runtime 2.5.2 → 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.
@@ -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;
@@ -50,6 +52,7 @@
50
52
  // - `derivePopDirection` hardens the popstate forward/back calc against an unchanged or
51
53
  // missing/NaN `state.index` that a bfcache restore can leave behind (#1076).
52
54
  import { doPreparation, doSwap, onPageLoad, triggerEvent, updateScrollPosition, } from "./events.js";
55
+ import { safePushState, safeReplaceState } from "./history-safe.js";
53
56
  import { detectScriptExecuted } from "./swap-functions.js";
54
57
  // Island re-bootstrap and deferred-cancel after body swap (W1B §12.2, §12.5).
55
58
  // mountNewIslands() is called after runScripts() and before onPageLoad().
@@ -145,19 +148,31 @@ let parser;
145
148
  // you can figure it using an index. On pushState the index is incremented so you
146
149
  // can use that to determine popstate if going forward or back.
147
150
  let currentHistoryIndex = 0;
148
- if (inBrowser) {
149
- if (history.state) {
150
- // Here we reloaded a page with history state
151
- // (e.g. history navigation from non-transition page or browser reload)
152
- currentHistoryIndex = history.state.index;
153
- scrollTo({ left: history.state.scrollX, top: history.state.scrollY });
154
- }
155
- else if (transitionEnabledOnThisPage()) {
156
- // This page is loaded from the browser address bar or via a link from extern,
157
- // it needs a state in the history
158
- history.replaceState({ index: currentHistoryIndex, scrollX, scrollY }, "");
159
- history.scrollRestoration = "manual";
160
- }
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;
161
176
  }
162
177
  // returns the contents of the page or null if the router can't deal with it.
163
178
  async function fetchHTML(href, init) {
@@ -274,7 +289,7 @@ historyCommittedEarly = false) => {
274
289
  scrollX,
275
290
  scrollY,
276
291
  };
277
- history.replaceState({
292
+ safeReplaceState({
278
293
  ...options.state,
279
294
  index: current.index,
280
295
  scrollX: current.scrollX,
@@ -282,7 +297,7 @@ historyCommittedEarly = false) => {
282
297
  }, "", to.href);
283
298
  }
284
299
  else {
285
- history.pushState({ ...options.state, index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 }, "", to.href);
300
+ safePushState({ ...options.state, index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 }, "", to.href);
286
301
  }
287
302
  }
288
303
  document.title = targetPageTitle;
@@ -311,7 +326,7 @@ historyCommittedEarly = false) => {
311
326
  const savedState = history.state;
312
327
  location.href = to.href; // this kills the history state on Firefox
313
328
  if (!history.state) {
314
- history.replaceState(savedState, ""); // this restores the history state
329
+ safeReplaceState(savedState, ""); // this restores the history state
315
330
  if (intraPage) {
316
331
  window.dispatchEvent(new PopStateEvent("popstate"));
317
332
  }
@@ -368,6 +383,9 @@ export function syncHistoryEntry(url, options = {}) {
368
383
  }
369
384
  return;
370
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();
371
389
  const to = new URL(url, location.href);
372
390
  // Cross-origin is not ours to manage. The History API would throw a native
373
391
  // SecurityError for a cross-origin URL, but we guard explicitly so the failure
@@ -382,7 +400,7 @@ export function syncHistoryEntry(url, options = {}) {
382
400
  // the tracked index rather than stamping NaN/undefined.
383
401
  const current = history.state;
384
402
  const index = current != null && Number.isFinite(current.index) ? current.index : currentHistoryIndex;
385
- history.replaceState({
403
+ safeReplaceState({
386
404
  ...options.state,
387
405
  index,
388
406
  scrollX: Number.isFinite(current?.scrollX) ? current.scrollX : scrollX,
@@ -401,7 +419,7 @@ export function syncHistoryEntry(url, options = {}) {
401
419
  // historyState branch below) scrollTo(0,0) when this entry is later
402
420
  // Forward-reopened, snapping the page to the top under the reopened
403
421
  // dialog. #1398.
404
- history.pushState({ ...options.state, index: ++currentHistoryIndex, scrollX, scrollY }, "", to.href);
422
+ safePushState({ ...options.state, index: ++currentHistoryIndex, scrollX, scrollY }, "", to.href);
405
423
  }
406
424
  // Always re-point originalLocation so the next transition()/onPopState uses the
407
425
  // correct "from" URL. Skipping this is exactly what makes a raw pushState
@@ -693,7 +711,7 @@ async function transition(direction, from, to, options, historyState, hasUAVisua
693
711
  scrollX,
694
712
  scrollY,
695
713
  };
696
- history.replaceState({
714
+ safeReplaceState({
697
715
  ...options.state,
698
716
  index: current.index,
699
717
  scrollX: current.scrollX,
@@ -701,7 +719,7 @@ async function transition(direction, from, to, options, historyState, hasUAVisua
701
719
  }, "", prepEvent.to.href);
702
720
  }
703
721
  else {
704
- history.pushState({ ...options.state, index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 }, "", prepEvent.to.href);
722
+ safePushState({ ...options.state, index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 }, "", prepEvent.to.href);
705
723
  }
706
724
  }
707
725
  if (supportsViewTransitions && !hasUAVisualTransition) {
@@ -809,6 +827,9 @@ export async function navigate(href, options) {
809
827
  }
810
828
  return;
811
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();
812
833
  await transition("forward", originalLocation, new URL(href, location.href), options ?? {});
813
834
  }
814
835
  function onPopState(ev) {
@@ -863,22 +884,22 @@ const onScrollEnd = () => {
863
884
  };
864
885
  // zfb-only addition (no Astro upstream — see file header "zfb-only additions").
865
886
  // WebKit serves Back navigations after an SPA route change from the bfcache:
866
- // the page is restored without re-evaluating this module, so the init-block
867
- // seed below (which sets `currentHistoryIndex` from `history.state.index`)
868
- // never re-runs and the tracked index can desync from the live history stack.
869
- // A desynced index makes onPopState's direction calc misfire, so Back skips an
870
- // entry. On a persisted (bfcache) restore we re-seed the tracked index and the
871
- // `originalLocation` "from" URL from the live state, and restore scroll —
872
- // mirroring the init-block seed. This is a no-op on a normal load (`persisted`
873
- // falsy/absent) and idempotent (re-seeding from the same state twice changes
874
- // 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).
875
896
  const onPageShow = (ev) => {
876
- // 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.
877
898
  if (!ev.persisted)
878
899
  return;
879
- // The init block sets `originalLocation` and `currentHistoryIndex` together;
880
- // re-sync both here so the next transition() gets the correct `from` URL
881
- // (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).
882
903
  originalLocation = new URL(location.href);
883
904
  const index = history.state?.index;
884
905
  if (Number.isFinite(index)) {
@@ -888,67 +909,116 @@ const onPageShow = (ev) => {
888
909
  scrollTo({ left: history.state.scrollX, top: history.state.scrollY });
889
910
  }
890
911
  };
891
- // initialization
892
- if (inBrowser) {
893
- if (supportsViewTransitions || getFallback() !== "none") {
894
- originalLocation = new URL(location.href);
895
- addEventListener("popstate", onPopState);
896
- addEventListener("load", onPageLoad);
897
- // Re-sync the tracked history index + scroll on a WebKit bfcache restore;
898
- // a no-op on normal loads. See onPageShow above.
899
- addEventListener("pageshow", onPageShow);
900
- // There's not a good way to record scroll position before a history back
901
- // navigation, so we will record it when the user has stopped scrolling.
902
- if ("onscrollend" in window)
903
- addEventListener("scrollend", onScrollEnd);
904
- else {
905
- // Keep track of state between intervals
906
- let intervalId, lastY, lastX, lastIndex;
907
- const scrollInterval = () => {
908
- // The interval can outlive its document context a test-env teardown or a
909
- // real page unload removes window/history before the 50ms tick. `typeof`
910
- // never throws even when the global is gone: stop the interval and bail
911
- // rather than dereferencing a torn-down global (the #1061 bug class; #1063).
912
- // In a real browser these globals always exist, so this path is test-env
913
- // only. clearInterval may itself be mid-teardown, so guard the call too.
914
- if (typeof window === "undefined" || typeof history === "undefined") {
915
- if (typeof clearInterval === "function")
916
- clearInterval(intervalId);
917
- intervalId = undefined;
918
- return;
919
- }
920
- // Check the index to see if a popstate event was fired
921
- if (lastIndex !== history.state?.index) {
922
- clearInterval(intervalId);
923
- intervalId = undefined;
924
- return;
925
- }
926
- // Check if the user stopped scrolling
927
- if (lastY === scrollY && lastX === scrollX) {
928
- // Cancel the interval and update scroll positions
929
- clearInterval(intervalId);
930
- intervalId = undefined;
931
- onScrollEnd();
932
- return;
933
- }
934
- else {
935
- ((lastY = scrollY), (lastX = scrollX));
936
- }
937
- };
938
- // We can't know when or how often scroll events fire, so we'll just use them to start intervals
939
- addEventListener("scroll", () => {
940
- if (intervalId !== undefined)
941
- return;
942
- ((lastIndex = history.state?.index), (lastY = scrollY), (lastX = scrollX));
943
- intervalId = window.setInterval(scrollInterval, 50);
944
- }, { 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 });
945
951
  }
946
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
+ }
947
959
  for (const script of document.getElementsByTagName("script")) {
948
960
  detectScriptExecuted(script);
949
961
  script.dataset["zfbExec"] = "";
950
962
  }
951
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
+ }
952
1022
  // ---- W3C3: click + form intercept, public idempotent init() ----
953
1023
  // Returns true when the modifier-key combo or mouse button means "open in new tab / download".
954
1024
  // Matches Astro's `leavesWindow` helper in ClientRouter.astro.
@@ -1045,26 +1115,44 @@ function handleSubmit(ev) {
1045
1115
  ev.preventDefault();
1046
1116
  navigate(action, options);
1047
1117
  }
1048
- // Guard flag — ensures click + submit listeners are registered only once even if
1049
- // 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).
1050
1121
  let initialized = false;
1051
1122
  /**
1052
- * Wire up the client-router's click and form-submit intercepts.
1053
- * 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`.
1054
1131
  *
1055
1132
  * @param _options - Forward-compat hook matching Astro's init() signature. Ignored in v1.
1056
1133
  */
1057
1134
  export function init(_options) {
1058
- if (initialized)
1059
- return;
1060
- // Latch the guard only AFTER the early-return guards below: an ineligible
1061
- // early call (no browser, or fallback "none") must not permanently latch
1062
- // `initialized`, or a later legitimately-eligible init() would no-op forever.
1063
1135
  if (!inBrowser)
1064
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.
1065
1146
  if (!supportsViewTransitions && getFallback() === "none")
1066
1147
  return;
1067
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();
1068
1156
  document.addEventListener("click", handleClick);
1069
1157
  document.addEventListener("submit", handleSubmit);
1070
1158
  // Prefetch hook intentionally omitted from v1 — see https://github.com/zudolab/zudo-doc/issues/1527