@takazudo/zfb-runtime 0.1.0-next.8 → 0.1.0-next.81

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +220 -15
  3. package/dist/client-router/events.d.ts +0 -1
  4. package/dist/client-router/events.js +2 -2
  5. package/dist/client-router/events.js.map +1 -1
  6. package/dist/client-router/index.d.ts +2 -3
  7. package/dist/client-router/index.js +2 -2
  8. package/dist/client-router/index.js.map +1 -1
  9. package/dist/client-router/prefetch.d.ts +0 -1
  10. package/dist/client-router/prefetch.js +126 -53
  11. package/dist/client-router/prefetch.js.map +1 -1
  12. package/dist/client-router/router.d.ts +28 -2
  13. package/dist/client-router/router.js +364 -30
  14. package/dist/client-router/router.js.map +1 -1
  15. package/dist/client-router/swap-functions.d.ts +0 -1
  16. package/dist/client-router/swap-functions.js +71 -11
  17. package/dist/client-router/swap-functions.js.map +1 -1
  18. package/dist/client-router/types.d.ts +4 -1
  19. package/dist/client-router/types.js.map +1 -1
  20. package/dist/client-router.d.ts +38 -2
  21. package/dist/client-router.js +56 -20
  22. package/dist/client-router.js.map +1 -1
  23. package/dist/framework.d.ts +0 -1
  24. package/dist/framework.js.map +1 -1
  25. package/dist/index.d.ts +2 -4
  26. package/dist/index.js +13 -4
  27. package/dist/index.js.map +1 -1
  28. package/dist/router.d.ts +32 -2
  29. package/dist/router.js +192 -33
  30. package/dist/router.js.map +1 -1
  31. package/dist/server.d.ts +2 -0
  32. package/dist/server.js +14 -0
  33. package/dist/server.js.map +1 -0
  34. package/dist/snapshot.d.ts +0 -1
  35. package/dist/snapshot.js +3 -2
  36. package/dist/snapshot.js.map +1 -1
  37. package/dist/view-transitions.d.ts +0 -1
  38. package/dist/view-transitions.js.map +1 -1
  39. package/package.json +26 -4
  40. package/dist/client-router/cssesc.d.ts +0 -9
  41. package/dist/client-router/cssesc.d.ts.map +0 -1
  42. package/dist/client-router/cssesc.js +0 -95
  43. package/dist/client-router/cssesc.js.map +0 -1
  44. package/dist/client-router/events.d.ts.map +0 -1
  45. package/dist/client-router/index.d.ts.map +0 -1
  46. package/dist/client-router/prefetch.d.ts.map +0 -1
  47. package/dist/client-router/router.d.ts.map +0 -1
  48. package/dist/client-router/swap-functions.d.ts.map +0 -1
  49. package/dist/client-router/types.d.ts.map +0 -1
  50. package/dist/client-router.d.ts.map +0 -1
  51. package/dist/framework.d.ts.map +0 -1
  52. package/dist/index.d.ts.map +0 -1
  53. package/dist/router.d.ts.map +0 -1
  54. package/dist/snapshot.d.ts.map +0 -1
  55. package/dist/view-transitions.d.ts.map +0 -1
@@ -24,7 +24,8 @@
24
24
  // - `inBrowser` evaluates to `typeof document !== "undefined"` rather than relying
25
25
  // on the SSR flag, because the runtime package serves both server- and client-side
26
26
  // code; same observable behavior in browser and on SSR.
27
- // - `announce()` is a TODO stub (W3C3 owns the route announcer).
27
+ // - `announce()` was a TODO stub at W1B port time; W3C3 implemented it and #1063 gave
28
+ // its 60ms timer explicit lifecycle ownership — see the route-announcer block below.
28
29
  //
29
30
  // W3C2 additions (this file):
30
31
  // - `navigate()` public entry.
@@ -33,9 +34,21 @@
33
34
  // from `history.state`, registers popstate / load / scrollend listeners, and
34
35
  // marks already-executed scripts with `dataset["zfbExec"] = ""`).
35
36
  //
36
- // W3C1 deferred to W3C3:
37
- // - `announce()` route-announcer implementation.
38
- // - Click + form intercept.
37
+ // W3C1 items deferred to — and since implemented in — W3C3:
38
+ // - `announce()` route-announcer implementation (see the route-announcer block below;
39
+ // timer lifecycle ownership hardened in #1063).
40
+ // - Click + form intercept (see `handleClick` / `handleSubmit` / `init()` below).
41
+ //
42
+ // zfb-only additions (no Astro upstream — #1076):
43
+ // - `onPageShow` pageshow/bfcache re-sync, registered inside the init block alongside
44
+ // popstate/load/scrollend. Verified against live Astro `main`: neither
45
+ // `transitions/router.ts` nor `ClientRouter.astro` has any pageshow/`persisted`
46
+ // handler, so this is original design, not a port. WebKit serves Back after an SPA
47
+ // route change from the bfcache without re-evaluating this module, desyncing the
48
+ // tracked `currentHistoryIndex` from the live history stack; `onPageShow` re-seeds it
49
+ // (and restores scroll) on a persisted restore — see the `onPageShow` block below.
50
+ // - `derivePopDirection` hardens the popstate forward/back calc against an unchanged or
51
+ // missing/NaN `state.index` that a bfcache restore can leave behind (#1076).
39
52
  import { doPreparation, doSwap, onPageLoad, triggerEvent, updateScrollPosition, } from "./events.js";
40
53
  import { detectScriptExecuted } from "./swap-functions.js";
41
54
  // Island re-bootstrap and deferred-cancel after body swap (W1B §12.2, §12.5).
@@ -53,6 +66,17 @@ const internalFetchHeaders = {};
53
66
  const inBrowser = typeof document !== "undefined";
54
67
  export const supportsViewTransitions = inBrowser && !!document.startViewTransition;
55
68
  export const transitionEnabledOnThisPage = () => inBrowser && !!document.querySelector('[name="zfb-view-transitions-enabled"]');
69
+ // A same-page Back/Forward traversal (two history entries sharing pathname +
70
+ // search) is served from the live DOM by the fast-path in transition() — no
71
+ // re-fetch, no re-swap, no lifecycle events. A per-request SSR page
72
+ // (`prerender = false`) whose server output can differ between two visits to
73
+ // the SAME url opts back INTO the fetch by emitting
74
+ // <meta name="zfb-traverse-refetch" content="true"> (via <ClientRouter
75
+ // traverseRefetch />); skipping the fetch on such a page would pin the stale
76
+ // first-render content. Read from the CURRENT document — for a same-page
77
+ // traverse the current document IS the target page. Mirrors the
78
+ // zfb-prefetch-disabled meta reader. (#1376)
79
+ const traverseRefetchOptedOut = () => inBrowser && !!document.querySelector('meta[name="zfb-traverse-refetch"][content="true"]');
56
80
  const samePage = (thisLocation, otherLocation) => thisLocation.pathname === otherLocation.pathname && thisLocation.search === otherLocation.search;
57
81
  // The previous navigation that might still be in processing
58
82
  let mostRecentNavigation;
@@ -61,24 +85,59 @@ let mostRecentTransition;
61
85
  // When we traverse the history, the window.location is already set to the new location.
62
86
  // This variable tells us where we came from
63
87
  let originalLocation;
64
- // Route announcer — ported from Astro's announce(). Creates (or reuses) a single
65
- // shared aria-live <div> per navigation, so screen readers announce the new page title.
88
+ // Route announcer — ported from Astro's announce(). Each navigation appends a fresh
89
+ // aria-live <div> (after removing any prior announcer, so exactly one ever exists) and
90
+ // writes the new page title into it 60ms later, so screen readers announce the new page.
91
+ //
92
+ // zfb deviation from Astro / design decision for #1063 (timer lifecycle ownership):
93
+ // - The 60ms timer id is stored and cleared when a newer navigation supersedes a
94
+ // still-pending announcement, so a superseded page's title is never written to a
95
+ // now-detached announcer. (Astro leaves the timer fire-and-forget.)
96
+ // - The previous announcer is removed before a new one is appended, so they never
97
+ // accumulate. We deliberately do NOT *reuse* one element across navigations (which
98
+ // #1063 floated): a reused live region is detached and re-inserted into the a11y
99
+ // tree on every body swap, and a repeated title would be a no-op text write — both
100
+ // make re-announcement unreliable across screen readers. Creating a fresh empty
101
+ // element and writing text 60ms later is Astro's proven, reliable announce trigger.
102
+ //
66
103
  // The 60ms delay is Astro's magic number: screen readers need to see the element change
67
104
  // and may miss it if it happens too quickly.
105
+ let announceTimer;
68
106
  const announce = () => {
69
- let div = document.createElement("div");
107
+ // A newer navigation supersedes any still-pending announcement: cancel the old
108
+ // timer so it can't write a now-stale title after this navigation's swap.
109
+ if (announceTimer !== undefined)
110
+ window.clearTimeout(announceTimer);
111
+ // Keep exactly one announcer: drop any leftover from a prior navigation that the
112
+ // body swap did not already detach.
113
+ document.querySelector(".zfb-route-announcer")?.remove();
114
+ const div = document.createElement("div");
70
115
  div.setAttribute("aria-live", "assertive");
71
116
  div.setAttribute("aria-atomic", "true");
72
117
  div.className = "zfb-route-announcer";
73
118
  document.body.append(div);
74
- setTimeout(() => {
75
- let title = document.title || document.querySelector("h1")?.textContent || location.pathname;
119
+ announceTimer = window.setTimeout(() => {
120
+ announceTimer = undefined;
121
+ // This timer can outlive its document context — a test-env teardown
122
+ // (vitest/happy-dom) or a real page unload removes `document` (and
123
+ // `location`) before the 60ms elapses. `typeof` is safe even when the
124
+ // global is gone, so bail out instead of throwing an unhandled
125
+ // ReferenceError (#1061); the announcement is moot once the page is gone.
126
+ // Both globals the callback dereferences are checked, not just `document`.
127
+ if (typeof document === "undefined" || typeof location === "undefined")
128
+ return;
129
+ const title = document.title || document.querySelector("h1")?.textContent || location.pathname;
76
130
  div.textContent = title;
77
- },
78
- // Screen readers need to see the element change; 60ms is Astro's empirically chosen delay.
79
- 60);
131
+ }, 60);
80
132
  };
81
133
  const PERSIST_ATTR = "data-zfb-transition-persist";
134
+ const attrValueSelector = (attr, value) => value === "" ? `[${attr}=""]` : `[${attr}=${CSS.escape(value)}]`;
135
+ const querySelectorWithAttrValue = (root, selectorPrefix, attr, value) => {
136
+ const escapedMatch = root.querySelector(`${selectorPrefix}${attrValueSelector(attr, value)}`);
137
+ if (escapedMatch)
138
+ return escapedMatch;
139
+ return (Array.from(root.querySelectorAll(`${selectorPrefix}[${attr}]`)).find((el) => el.getAttribute(attr) === value) ?? null);
140
+ };
82
141
  const DIRECTION_ATTR = "data-zfb-transition";
83
142
  const OLD_NEW_ATTR = "data-zfb-transition-fallback";
84
143
  let parser;
@@ -130,10 +189,19 @@ async function fetchHTML(href, init) {
130
189
  export function getFallback() {
131
190
  const el = document.querySelector('[name="zfb-view-transitions-fallback"]');
132
191
  if (el) {
133
- return el.getAttribute("content");
192
+ // Astro casts `content` straight to Fallback; harden against an arbitrary/typo'd
193
+ // attribute value by validating against the known set and defaulting to "animate".
194
+ const content = el.getAttribute("content");
195
+ if (content === "none" || content === "animate" || content === "swap") {
196
+ return content;
197
+ }
134
198
  }
135
199
  return "animate";
136
200
  }
201
+ // Upper bound (ms) for waiting on a single external <script>'s load/error in
202
+ // runScripts(). Not an Astro upstream value — added so a slow/broken external
203
+ // script can never hang the transition indefinitely.
204
+ const EXTERNAL_SCRIPT_WAIT_TIMEOUT = 5000;
137
205
  function runScripts() {
138
206
  let wait = Promise.resolve();
139
207
  let needsWaitForInlineModuleScript = false;
@@ -159,8 +227,17 @@ function runScripts() {
159
227
  newScript.innerHTML = script.innerHTML;
160
228
  for (const attr of script.attributes) {
161
229
  if (attr.name === "src") {
230
+ // Astro awaits onload/onerror with no upper bound; a slow or broken
231
+ // external <script> would then hang the whole transition. Race the
232
+ // load against a timeout so the transition can always proceed.
162
233
  const p = new Promise((r) => {
163
- newScript.onload = newScript.onerror = r;
234
+ let timer;
235
+ const done = () => {
236
+ clearTimeout(timer);
237
+ r();
238
+ };
239
+ newScript.onload = newScript.onerror = done;
240
+ timer = setTimeout(done, EXTERNAL_SCRIPT_WAIT_TIMEOUT);
164
241
  });
165
242
  wait = wait.then(() => p);
166
243
  }
@@ -173,14 +250,30 @@ function runScripts() {
173
250
  }
174
251
  // Add a new entry to the browser history. This also sets the new page in the browser address bar.
175
252
  // Sets the scroll position according to the hash fragment of the new location.
176
- const moveToLocation = (to, from, options, pageTitleForBrowserHistory, historyState) => {
253
+ const moveToLocation = (to, from, options, pageTitleForBrowserHistory, historyState,
254
+ // True when transition() already committed a history entry for THIS
255
+ // navigation before the swap ran (the WebKit-workaround early commit
256
+ // below). `to` here may differ from that committed entry's URL if a
257
+ // `zfb:before-swap` listener mutated `event.to` (writable per Astro
258
+ // parity) after the early commit already ran — in that case we must
259
+ // REPLACE the already-committed entry instead of pushing a second one, or
260
+ // a single navigation would leave two history entries (a phantom Back
261
+ // stop). #1398.
262
+ historyCommittedEarly = false) => {
177
263
  const intraPage = samePage(from, to);
178
264
  const targetPageTitle = document.title;
179
265
  document.title = pageTitleForBrowserHistory;
180
266
  let scrolledToTop = false;
181
267
  if (to.href !== location.href && !historyState) {
182
- if (options.history === "replace") {
183
- const current = history.state;
268
+ if (options.history === "replace" || historyCommittedEarly) {
269
+ // Astro reads current.index/scrollX/scrollY directly; `history.state` can be
270
+ // null (page entered without a transition state), which would throw a
271
+ // TypeError. Fall back to a synthesized state from the tracked index/scroll.
272
+ const current = history.state ?? {
273
+ index: currentHistoryIndex,
274
+ scrollX,
275
+ scrollY,
276
+ };
184
277
  history.replaceState({
185
278
  ...options.state,
186
279
  index: current.index,
@@ -202,7 +295,12 @@ const moveToLocation = (to, from, options, pageTitleForBrowserHistory, historySt
202
295
  scrolledToTop = true;
203
296
  }
204
297
  if (historyState) {
205
- scrollTo(historyState.scrollX, historyState.scrollY);
298
+ // Raw-History consumers may push partial/empty state (`{}`), so the tracked
299
+ // scroll fields can be absent or non-finite. Never scrollTo(undefined, …) —
300
+ // leave scroll where it is rather than jumping to (0,0). #1376.
301
+ if (Number.isFinite(historyState.scrollX) && Number.isFinite(historyState.scrollY)) {
302
+ scrollTo(historyState.scrollX, historyState.scrollY);
303
+ }
206
304
  }
207
305
  else {
208
306
  if (to.hash) {
@@ -227,15 +325,111 @@ const moveToLocation = (to, from, options, pageTitleForBrowserHistory, historySt
227
325
  history.scrollRestoration = "manual";
228
326
  }
229
327
  };
328
+ let syncHistoryEntryOnServerWarned = false;
329
+ /**
330
+ * Write a router-managed history entry (push or replace) WITHOUT any
331
+ * navigation, DOM, or lifecycle side effect. This is the supported path for
332
+ * consumers deep-linking transient UI state (dialogs/modals, a photo
333
+ * viewer's `/photos/<slug>/` URL). Hand-rolled raw `history.pushState`
334
+ * desyncs `originalLocation` and the index bookkeeping that popstate
335
+ * direction detection ({@link derivePopDirection}) and the same-page
336
+ * traverse fast-path depend on; `navigate()` can't be used instead because
337
+ * it forces a fetch. See #1377 / #1374.
338
+ *
339
+ * Never scrolls the viewport itself — but the entry it writes IS stamped
340
+ * with the CURRENT scroll position (`scrollX`/`scrollY` at call time), not
341
+ * `(0, 0)` (issue #1398). This matters only for a later Forward-traversal
342
+ * back to this entry: the same-page traverse fast-path restores whatever
343
+ * scroll position was stamped on the entry, so a same-page push (e.g.
344
+ * opening a dialog) does not snap the underlying page to the top when the
345
+ * dialog is later reopened via Forward.
346
+ *
347
+ * @param url - The URL to write. Cross-origin URLs throw rather than
348
+ * silently falling back to a full-page load.
349
+ * @param options.replace - Use `history.replaceState` instead of
350
+ * `history.pushState` (no new Back-button entry). Default: push.
351
+ * @param options.state - Merged into the entry's `history.state`; the
352
+ * router's own bookkeeping keys (`index`, `scrollX`, `scrollY`) always win
353
+ * on a colliding key.
354
+ */
355
+ export function syncHistoryEntry(url, options = {}) {
356
+ // SSR guard: no window/history to touch. Mirror navigate()'s server no-op +
357
+ // one-time console warning. Checked at call time via `typeof document` (not the
358
+ // module-eval `inBrowser` const) so the branch is reachable and behavior is
359
+ // identical in a real environment (document is always present in a browser).
360
+ if (typeof document === "undefined") {
361
+ if (!syncHistoryEntryOnServerWarned) {
362
+ // instantiate an error for the stacktrace to show to user.
363
+ const warning = new Error("syncHistoryEntry() was called during a server side render. This may be unintentional as it is expected to be called in response to a client-side user interaction. Please make sure that your usage is correct.");
364
+ warning.name = "Warning";
365
+ // biome-ignore lint/suspicious/noConsole: allowed
366
+ console.warn(warning);
367
+ syncHistoryEntryOnServerWarned = true;
368
+ }
369
+ return;
370
+ }
371
+ const to = new URL(url, location.href);
372
+ // Cross-origin is not ours to manage. The History API would throw a native
373
+ // SecurityError for a cross-origin URL, but we guard explicitly so the failure
374
+ // is a clear, documented contract violation rather than an opaque platform
375
+ // error — and never a silent full-page load. #1377.
376
+ if (to.origin !== location.origin) {
377
+ throw new Error(`syncHistoryEntry(): refusing to write a cross-origin history entry for "${to.href}" (current origin: ${location.origin}).`);
378
+ }
379
+ if (options.replace) {
380
+ // Replace keeps the current entry's index. A consumer migrating from raw
381
+ // history.pushState may have left a missing/invalid index, so fall back to
382
+ // the tracked index rather than stamping NaN/undefined.
383
+ const current = history.state;
384
+ const index = current != null && Number.isFinite(current.index) ? current.index : currentHistoryIndex;
385
+ history.replaceState({
386
+ ...options.state,
387
+ index,
388
+ scrollX: Number.isFinite(current?.scrollX) ? current.scrollX : scrollX,
389
+ scrollY: Number.isFinite(current?.scrollY) ? current.scrollY : scrollY,
390
+ }, "", to.href);
391
+ }
392
+ else {
393
+ // Persist the CURRENT (outgoing) entry's latest scroll before pushing, so a
394
+ // later Back restoration can't lose scroll that `scrollend` hasn't flushed
395
+ // yet — the same guard transition() applies before a non-traverse push.
396
+ updateScrollPosition({ scrollX, scrollY });
397
+ // Stamp the CURRENT scroll on the freshly-pushed entry too — NOT (0,0).
398
+ // This is a same-page push (dialog/photo-viewer pattern): the underlying
399
+ // page never actually scrolled anywhere, it just gained an overlay. A
400
+ // (0,0)-stamped entry would make the traverse fast-path (moveToLocation's
401
+ // historyState branch below) scrollTo(0,0) when this entry is later
402
+ // Forward-reopened, snapping the page to the top under the reopened
403
+ // dialog. #1398.
404
+ history.pushState({ ...options.state, index: ++currentHistoryIndex, scrollX, scrollY }, "", to.href);
405
+ }
406
+ // Always re-point originalLocation so the next transition()/onPopState uses the
407
+ // correct "from" URL. Skipping this is exactly what makes a raw pushState
408
+ // desync the router (a same-page Back would miss the traverse fast path).
409
+ originalLocation = to;
410
+ }
230
411
  function preloadStyleLinks(newDocument) {
231
412
  const links = [];
232
413
  for (const el of newDocument.querySelectorAll("head link[rel=stylesheet]")) {
414
+ const persistId = el.getAttribute(PERSIST_ATTR);
415
+ const href = el.getAttribute("href");
416
+ const existingSelectors = [
417
+ persistId !== null ? attrValueSelector(PERSIST_ATTR, persistId) : null,
418
+ href !== null ? `link[rel=stylesheet]${attrValueSelector("href", href)}` : null,
419
+ ].filter((selector) => selector !== null);
420
+ const existingLink = (existingSelectors.length ? document.querySelector(existingSelectors.join(", ")) : null) ??
421
+ (persistId !== null
422
+ ? querySelectorWithAttrValue(document, "", PERSIST_ATTR, persistId)
423
+ : null) ??
424
+ (href !== null
425
+ ? querySelectorWithAttrValue(document, "link[rel=stylesheet]", "href", href)
426
+ : null);
233
427
  // Do not preload links that are already on the page.
234
- if (!document.querySelector(`[${PERSIST_ATTR}="${el.getAttribute(PERSIST_ATTR)}"], link[rel=stylesheet][href="${el.getAttribute("href")}"]`)) {
428
+ if (href !== null && !existingLink) {
235
429
  const c = document.createElement("link");
236
430
  c.setAttribute("rel", "preload");
237
431
  c.setAttribute("as", "style");
238
- c.setAttribute("href", el.getAttribute("href"));
432
+ c.setAttribute("href", href);
239
433
  links.push(new Promise((resolve) => {
240
434
  ["load", "error"].forEach((evName) => c.addEventListener(evName, resolve));
241
435
  document.head.append(c);
@@ -248,7 +442,9 @@ function preloadStyleLinks(newDocument) {
248
442
  // if !popstate, update the history entry and scroll position according to toLocation
249
443
  // if popState is given, this holds the scroll position for history navigation
250
444
  // if fallback === "animate" then simulate view transitions
251
- async function updateDOM(preparationEvent, options, currentTransition, historyState, fallback) {
445
+ async function updateDOM(preparationEvent, options, currentTransition, historyState, fallback,
446
+ // Forwarded to moveToLocation() — see its parameter doc. #1398.
447
+ historyCommittedEarly = false) {
252
448
  async function animate(phase) {
253
449
  function isInfinite(animation) {
254
450
  const effect = animation.effect;
@@ -288,9 +484,15 @@ async function updateDOM(preparationEvent, options, currentTransition, historySt
288
484
  // trees receive render(null, element) / root.unmount() and their useEffect
289
485
  // cleanups fire. Must happen after cancelPendingIslands() and before doSwap()
290
486
  // so document.body still points to the old body.
291
- unmountIslands();
487
+ //
488
+ // Pass the incoming body so unmountIslands can SKIP islands that swapBodyElement
489
+ // will lift into the new body (a persisted marker matched on both sides). Those
490
+ // nodes are physically moved, not discarded, so their component instance and
491
+ // state must survive the swap — unmounting them here would empty the container
492
+ // before the lift and defeat data-zfb-transition-persist entirely (issue #1389).
493
+ unmountIslands(document.body, preparationEvent.newDocument.body);
292
494
  const swapEvent = await doSwap(preparationEvent, currentTransition.viewTransition, animateFallbackOld);
293
- moveToLocation(swapEvent.to, swapEvent.from, options, pageTitleForBrowserHistory, historyState);
495
+ moveToLocation(swapEvent.to, swapEvent.from, options, pageTitleForBrowserHistory, historyState, historyCommittedEarly);
294
496
  triggerEvent("zfb:after-swap");
295
497
  // Resolve the finished promise of the simulation's ViewTransition.
296
498
  // For 'animate', wait for the new-page animation to complete first.
@@ -331,7 +533,12 @@ async function transition(direction, from, to, options, historyState, hasUAVisua
331
533
  updateScrollPosition({ scrollX, scrollY });
332
534
  }
333
535
  if (samePage(from, to) && !options.formData) {
334
- if ((direction !== "back" && to.hash) || (direction === "back" && from.hash)) {
536
+ if (
537
+ // Same-page Back/Forward: serve from the live DOM (no re-fetch/re-swap)
538
+ // unless this page opted back into the fetch (per-request SSR). #1374/#1376.
539
+ (navigationType === "traverse" && !traverseRefetchOptedOut()) ||
540
+ (direction !== "back" && to.hash) ||
541
+ (direction === "back" && from.hash)) {
335
542
  moveToLocation(to, from, options, document.title, historyState);
336
543
  if (currentNavigation === mostRecentNavigation)
337
544
  mostRecentNavigation = undefined;
@@ -369,8 +576,11 @@ async function transition(direction, from, to, options, historyState, hasUAVisua
369
576
  //
370
577
  // Note: getNamedItem can return null in real life, even if TypeScript doesn't think so, hence
371
578
  // the ?.
579
+ // `form` can be null (closest() miss, or a form-less form-associated
580
+ // element) — `!= null` covers both null and undefined so Reflect.get
581
+ // is never invoked on a non-element receiver ("Illegal invocation").
372
582
  init.body =
373
- form !== undefined &&
583
+ form != null &&
374
584
  Reflect.get(HTMLFormElement.prototype, "attributes", form).getNamedItem("enctype")
375
585
  ?.value === "application/x-www-form-urlencoded"
376
586
  ? new URLSearchParams(preparationEvent.formData)
@@ -446,10 +656,58 @@ async function transition(direction, from, to, options, historyState, hasUAVisua
446
656
  return;
447
657
  }
448
658
  document.documentElement.setAttribute(DIRECTION_ATTR, prepEvent.direction);
659
+ // Commit the SPA history entry BEFORE the transition's update callback runs.
660
+ //
661
+ // WebKit (all iOS browsers, incl. iOS Chrome) does not reliably commit a
662
+ // distinct back/forward history entry when history.pushState() is issued
663
+ // *inside* the document.startViewTransition() update callback: the URL bar
664
+ // updates but no new entry is created, so a single Back press falls off the
665
+ // site to the browser home (zudolab/zzmod#662). Committing the entry here —
666
+ // outside the transition's update callback — makes WebKit create it; Chromium
667
+ // is unaffected either way. moveToLocation() (called later from updateDOM,
668
+ // inside the callback) then no-ops its own push/replace via the
669
+ // `to.href !== location.href` guard, since location is already at the
670
+ // committed URL, so currentHistoryIndex is not double-advanced.
671
+ //
672
+ // We use prepEvent.to (redirects are already resolved into it during
673
+ // preparation) and write before the swap, while document.title is still the
674
+ // old page's title — so the back entry keeps the correct (old-page) title
675
+ // without needing moveToLocation's title juggle. Writing here also covers the
676
+ // non-VT fallback path below in one place.
677
+ //
678
+ // Traverse (popstate) navigations carry historyState: the browser has already
679
+ // moved, so they must NOT create a new entry here.
680
+ //
681
+ // Tracks whether this block ran (push OR replace) so moveToLocation (called
682
+ // later, from updateDOM) knows a commit already happened for THIS navigation
683
+ // — see moveToLocation's `historyCommittedEarly` parameter doc. #1398.
684
+ let historyCommittedEarly = false;
685
+ if (!historyState && prepEvent.to.href !== location.href) {
686
+ historyCommittedEarly = true;
687
+ if (options.history === "replace") {
688
+ // Mirror of the moveToLocation replace-path guard: `history.state` can be
689
+ // null here too (page entered without a transition state), so synthesize a
690
+ // state from the tracked index/scroll instead of dereferencing null.
691
+ const current = history.state ?? {
692
+ index: currentHistoryIndex,
693
+ scrollX,
694
+ scrollY,
695
+ };
696
+ history.replaceState({
697
+ ...options.state,
698
+ index: current.index,
699
+ scrollX: current.scrollX,
700
+ scrollY: current.scrollY,
701
+ }, "", prepEvent.to.href);
702
+ }
703
+ else {
704
+ history.pushState({ ...options.state, index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 }, "", prepEvent.to.href);
705
+ }
706
+ }
449
707
  if (supportsViewTransitions && !hasUAVisualTransition) {
450
708
  // This automatically cancels any previous transition
451
709
  // We also already took care that the earlier update callback got through
452
- currentTransition.viewTransition = document.startViewTransition(async () => await updateDOM(prepEvent, options, currentTransition, historyState));
710
+ currentTransition.viewTransition = document.startViewTransition(async () => await updateDOM(prepEvent, options, currentTransition, historyState, undefined, historyCommittedEarly));
453
711
  }
454
712
  else {
455
713
  // Simulation mode requires a bit more manual work.
@@ -459,7 +717,7 @@ async function transition(direction, from, to, options, historyState, hasUAVisua
459
717
  const updateDone = (async () => {
460
718
  // Immediately paused to set up the ViewTransition object for Fallback mode
461
719
  await Promise.resolve(); // hop through the micro task queue
462
- await updateDOM(prepEvent, options, currentTransition, historyState, hasUAVisualTransition ? "swap" : getFallback());
720
+ await updateDOM(prepEvent, options, currentTransition, historyState, hasUAVisualTransition ? "swap" : getFallback(), historyCommittedEarly);
463
721
  return undefined;
464
722
  })();
465
723
  // When the updateDone promise is settled,
@@ -539,6 +797,18 @@ export async function navigate(href, options) {
539
797
  }
540
798
  return;
541
799
  }
800
+ if (!supportsViewTransitions && getFallback() === "none") {
801
+ const url = new URL(href, location.href);
802
+ // Honor the history option on the full-load fallback path: "replace"
803
+ // must not leave a back-button entry, so use location.replace().
804
+ if (options?.history === "replace") {
805
+ location.replace(url.href);
806
+ }
807
+ else {
808
+ location.href = url.href;
809
+ }
810
+ return;
811
+ }
542
812
  await transition("forward", originalLocation, new URL(href, location.href), options ?? {});
543
813
  }
544
814
  function onPopState(ev) {
@@ -558,10 +828,29 @@ function onPopState(ev) {
558
828
  }
559
829
  const state = history.state;
560
830
  const nextIndex = state.index;
561
- const direction = nextIndex > currentHistoryIndex ? "forward" : "back";
562
- currentHistoryIndex = nextIndex;
831
+ const direction = derivePopDirection(nextIndex, currentHistoryIndex);
832
+ // Only advance the tracked index when the entry carries a usable index.
833
+ // After a bfcache restore the index can be missing/NaN; clobbering the
834
+ // tracked value with NaN would poison every subsequent direction calc, so
835
+ // keep the last known-good index in that case (see derivePopDirection).
836
+ if (Number.isFinite(nextIndex)) {
837
+ currentHistoryIndex = nextIndex;
838
+ }
563
839
  transition(direction, originalLocation, new URL(location.href), {}, state, ev.hasUAVisualTransition);
564
840
  }
841
+ // Decide forward vs back from the popped entry's index against the last
842
+ // tracked index. Two desync cases a WebKit bfcache restore can produce:
843
+ // - missing / NaN `next` (restored entry lost its index): handled explicitly
844
+ // below — we cannot prove a forward move, so treat it as "back" (the safe
845
+ // default; a forward misclassification is what makes Back skip to the wrong
846
+ // page).
847
+ // - `next === current` (index unchanged after a desync/replace): falls out of
848
+ // the `>` comparison as "back" — not a forward navigation.
849
+ function derivePopDirection(nextIndex, trackedIndex) {
850
+ if (!Number.isFinite(nextIndex))
851
+ return "back";
852
+ return nextIndex > trackedIndex ? "forward" : "back";
853
+ }
565
854
  const onScrollEnd = () => {
566
855
  // NOTE: our "popstate" event handler may call `pushState()` or
567
856
  // `replaceState()` and then `scrollTo()`, which will fire "scroll" and
@@ -572,12 +861,42 @@ const onScrollEnd = () => {
572
861
  updateScrollPosition({ scrollX, scrollY });
573
862
  }
574
863
  };
864
+ // zfb-only addition (no Astro upstream — see file header "zfb-only additions").
865
+ // 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).
875
+ const onPageShow = (ev) => {
876
+ // Normal (non-bfcache) loads already ran the init-block seed; leave them be.
877
+ if (!ev.persisted)
878
+ 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).
882
+ originalLocation = new URL(location.href);
883
+ const index = history.state?.index;
884
+ if (Number.isFinite(index)) {
885
+ currentHistoryIndex = index;
886
+ }
887
+ if (history.state) {
888
+ scrollTo({ left: history.state.scrollX, top: history.state.scrollY });
889
+ }
890
+ };
575
891
  // initialization
576
892
  if (inBrowser) {
577
893
  if (supportsViewTransitions || getFallback() !== "none") {
578
894
  originalLocation = new URL(location.href);
579
895
  addEventListener("popstate", onPopState);
580
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);
581
900
  // There's not a good way to record scroll position before a history back
582
901
  // navigation, so we will record it when the user has stopped scrolling.
583
902
  if ("onscrollend" in window)
@@ -586,6 +905,18 @@ if (inBrowser) {
586
905
  // Keep track of state between intervals
587
906
  let intervalId, lastY, lastX, lastIndex;
588
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
+ }
589
920
  // Check the index to see if a popstate event was fired
590
921
  if (lastIndex !== history.state?.index) {
591
922
  clearInterval(intervalId);
@@ -726,11 +1057,14 @@ let initialized = false;
726
1057
  export function init(_options) {
727
1058
  if (initialized)
728
1059
  return;
729
- initialized = true;
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.
730
1063
  if (!inBrowser)
731
1064
  return;
732
1065
  if (!supportsViewTransitions && getFallback() === "none")
733
1066
  return;
1067
+ initialized = true;
734
1068
  document.addEventListener("click", handleClick);
735
1069
  document.addEventListener("submit", handleSubmit);
736
1070
  // Prefetch hook intentionally omitted from v1 — see https://github.com/zudolab/zudo-doc/issues/1527