@takazudo/zfb-runtime 0.1.0-next.76 → 0.1.0-next.78

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
@@ -204,6 +204,50 @@ on its own. See the [Client-Side Routing concept
204
204
  guide](https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/client-side-routing/)
205
205
  for the full API.
206
206
 
207
+ #### `navigate()` needs `<ClientRouter />` mounted on the current page
208
+
209
+ The root barrel exports `navigate` and `syncHistoryEntry`, but **not** `init`
210
+ — importing either of those two on their own is not enough to get soft
211
+ navigation. `navigate()` checks for the `<meta
212
+ name="zfb-view-transitions-enabled">` tag that `<ClientRouter />` renders into
213
+ `<head>` before it will do a soft swap; with that tag absent it silently falls
214
+ back to a full `location.href` load. Mount `<ClientRouter />` in the layout
215
+ `<head>` of every page that should be soft-navigable — it both renders that
216
+ meta tag and calls `init()` (click/form interception) as a side effect. `init`
217
+ itself is exported from the `@takazudo/zfb-runtime/client-router` subpath, not
218
+ the root barrel, for the rare case where you want to call it directly instead
219
+ of mounting the component.
220
+
221
+ #### Persisting elements and island state across navigations (`data-zfb-transition-persist`)
222
+
223
+ Add `data-zfb-transition-persist="<id>"` to an element (matched by the same
224
+ `id` on both the outgoing and incoming page) to keep it alive across a soft
225
+ navigation instead of letting it be discarded and re-created — the router
226
+ lifts it out of the old body and reattaches it into the new one. This works
227
+ on plain elements (`<video>`, `<canvas>`) and on island wrapper elements
228
+ (`[data-zfb-island]`) alike; for an island, the live component instance (and
229
+ its internal state) survives too, not just the DOM node.
230
+
231
+ ```tsx
232
+ <div data-zfb-island="SidebarTree" data-zfb-transition-persist="sidebar-tree" data-props={props}>
233
+ <SidebarTree {...props} />
234
+ </div>
235
+ ```
236
+
237
+ By default, a persisted island still gets its `data-props` refreshed to match
238
+ the incoming page on every swap; if the refreshed props differ from what the
239
+ live instance currently holds, the island is unmounted and remounted fresh
240
+ with the new props (so it can't get stuck showing stale data), otherwise
241
+ nothing happens and the instance's state carries over untouched. Set
242
+ `data-zfb-transition-persist-props` to any value other than `"false"`
243
+ (conventionally `"true"`) to opt OUT of that props refresh and keep the
244
+ island's current props/state exactly as they are, regardless of what the
245
+ incoming page's props would have been — the attribute's absence, or the
246
+ literal string `"false"`, is what makes props refresh (mirrors Astro's
247
+ `data-astro-transition-persist-props`). See the [Client-Side Routing concept
248
+ guide](https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/client-side-routing/)
249
+ for the full walkthrough, including when to reach for the opt-out.
250
+
207
251
  ### `createPageRouter(options) → PageRouter`
208
252
 
209
253
  Build a fetch-handler that serves the supplied pages. The returned
@@ -145,14 +145,6 @@ export function prefetch(url, opts = {}) {
145
145
  return;
146
146
  executePrefetch(href, opts).catch(() => { });
147
147
  }
148
- /**
149
- * Returns a pair of [enterHandler, leaveHandler] that queue and cancel an idle
150
- * prefetch callback, keyed on the supplied per-trigger cancel-handle map.
151
- *
152
- * Both the pointer (hover) and focus triggers use identical queue/cancel logic;
153
- * the only difference between them is which map tracks their pending handles.
154
- * Parameterising by the map eliminates that duplication.
155
- */
156
148
  function makeEnterLeaveHandlers(cancelHandles) {
157
149
  function enterHandler(e) {
158
150
  const link = e.target.closest("a[href]");
@@ -160,6 +152,27 @@ function makeEnterLeaveHandlers(cancelHandles) {
160
152
  return;
161
153
  if (!shouldPrefetchLink(link, "hover"))
162
154
  return;
155
+ // A handle is already queued for this link — the pointer/focus moved
156
+ // between nested descendants of the SAME link, which re-fires enter on
157
+ // each one. Keep the original debounce window instead of overwriting it
158
+ // with a fresh requestIdleCallback/timeout that a busy pointer could keep
159
+ // resetting indefinitely. #1398.
160
+ if (cancelHandles.has(link))
161
+ return;
162
+ // The pending debounce already FIRED for this link — its idle callback ran
163
+ // prefetch(link.href), so the handle is gone from cancelHandles but the
164
+ // href is now prefetched (or in-flight). A later enter on the SAME link
165
+ // (e.g. an intra-link child->child move whose first callback fired in the
166
+ // gap between the two pointer moves — which the synchronous-dispatch L2
167
+ // suite cannot reproduce but a real browser does) must NOT queue a second,
168
+ // redundant idle callback whose prefetch() would only no-op. This is the
169
+ // fire+requeue companion to the cancel+requeue guard above. A failed
170
+ // prefetch leaves the href in NEITHER set (executePrefetch deletes it from
171
+ // inFlight without adding it to prefetched), so retry-after-error still
172
+ // works. #1398.
173
+ const resolvedHref = resolveHref(link.href);
174
+ if (resolvedHref && (prefetched.has(resolvedHref) || inFlight.has(resolvedHref)))
175
+ return;
163
176
  // Use requestIdleCallback if available, else a small timeout.
164
177
  const fire = () => {
165
178
  cancelHandles.delete(link);
@@ -178,6 +191,12 @@ function makeEnterLeaveHandlers(cancelHandles) {
178
191
  const link = e.target.closest("a[href]");
179
192
  if (!link)
180
193
  return;
194
+ // The pointer/focus is moving to relatedTarget. If that's still inside
195
+ // the same link (a nested descendant), this is NOT a real leave — skip
196
+ // the cancel so the pending prefetch survives intra-link moves. #1398.
197
+ const related = e.relatedTarget;
198
+ if (related instanceof Node && link.contains(related))
199
+ return;
181
200
  const handle = cancelHandles.get(link);
182
201
  if (handle === undefined)
183
202
  return;
@@ -1 +1 @@
1
- {"version":3,"file":"prefetch.js","sourceRoot":"","sources":["../../src/client-router/prefetch.ts"],"names":[],"mappings":"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","sourcesContent":["/// <reference lib=\"dom\" />\n/// <reference lib=\"dom.iterable\" />\n// `@takazudo/zfb-runtime` — prefetch module.\n//\n// Ported from Astro's prefetch module (packages/astro/src/prefetch/index.ts).\n// Source: https://github.com/withastro/astro\n// Issue: Takazudo/zudo-front-builder#276\n//\n// Mechanical renames per W1B §13.5:\n// astro:* event names → zfb:*\n// data-astro-prefetch → data-zfb-prefetch\n// __PREFETCH_DISABLED__ → <meta name=\"zfb-prefetch-disabled\" content=\"true\">\n//\n// DISABLED-FLAG CONTRACT — exact meta tag name and content value are locked:\n// meta[name=\"zfb-prefetch-disabled\"][content=\"true\"]\n// Both this module and sibling sub-issue #272-config pin this verbatim.\n\nexport type PrefetchStrategy = \"hover\" | \"viewport\" | \"load\" | \"tap\";\n\nexport interface PrefetchInitOptions {\n prefetchAll?: boolean;\n defaultStrategy?: PrefetchStrategy;\n}\n\nexport interface PrefetchOptions {\n ignoreSlowConnection?: boolean;\n with?: \"link\" | \"fetch\";\n}\n\n// Module-level state — persists across SPA navigations within a session.\nlet initialized = false;\nlet prefetchAll = false;\nlet defaultStrategy: PrefetchStrategy = \"hover\";\n\n// Set of hrefs that have been successfully prefetched.\nconst prefetched = new Set<string>();\n// Map of in-flight prefetch promises for dedup (also used as the concurrent short-circuit guard).\nconst inFlight = new Map<string, Promise<void>>();\n\n// How long to wait for a <link rel=prefetch>'s load/error before treating\n// the prefetch as settled anyway (see executePrefetch).\nconst LINK_SETTLE_TIMEOUT_MS = 10_000;\n\n// The viewport observer — retained across post-swap re-scans so new elements\n// can be observed without recreating the observer.\nlet viewportObserver: IntersectionObserver | null = null;\n\n// Hover cancel handle — set when a pointerenter idle-callback fires, cleared on\n// pointerleave before the callback executes.\nconst hoverCancelHandles = new Map<Element, ReturnType<typeof setTimeout> | number>();\n\n// Focus cancel handle — set when a focusin idle-callback fires, cleared on\n// focusout before the callback executes.\nconst focusCancelHandles = new Map<Element, ReturnType<typeof setTimeout> | number>();\n\n/**\n * Reset all module-level state. Exported for test isolation only — not part of\n * the public API and not re-exported from any barrel.\n */\nexport function __resetForTests(): void {\n initialized = false;\n prefetchAll = false;\n defaultStrategy = \"hover\";\n prefetched.clear();\n inFlight.clear();\n if (viewportObserver) {\n viewportObserver.disconnect();\n viewportObserver = null;\n }\n hoverCancelHandles.clear();\n focusCancelHandles.clear();\n}\n\n// ---------------------------------------------------------------------------\n// Feature detection\n// ---------------------------------------------------------------------------\n\nfunction supportsLinkPrefetch(): boolean {\n const link = document.createElement(\"link\");\n return link.relList?.supports?.(\"prefetch\") ?? false;\n}\n\nfunction isSlowConnection(): boolean {\n const nav = navigator as Navigator & {\n connection?: { saveData?: boolean; effectiveType?: string };\n };\n return (\n nav.connection?.saveData === true ||\n nav.connection?.effectiveType === \"2g\" ||\n nav.connection?.effectiveType === \"slow-2g\"\n );\n}\n\n// ---------------------------------------------------------------------------\n// Core prefetch logic\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve an href to an absolute URL string keyed against the current origin.\n * Returns null for cross-origin hrefs (these are skipped entirely).\n */\nfunction resolveHref(href: string): string | null {\n try {\n const url = new URL(href, location.href);\n if (url.origin !== location.origin) return null;\n return url.href;\n } catch {\n return null;\n }\n}\n\n/**\n * Execute a single prefetch for the given absolute href.\n * Uses <link rel=\"prefetch\"> (preferred) or fetch() fallback.\n */\nfunction executePrefetch(href: string, opts: PrefetchOptions): Promise<void> {\n const existing = inFlight.get(href);\n if (existing) return existing;\n\n const p: Promise<void> = (async () => {\n try {\n const method = opts.with ?? (supportsLinkPrefetch() ? \"link\" : \"fetch\");\n if (method === \"link\") {\n const link = document.createElement(\"link\");\n link.rel = \"prefetch\";\n link.href = href;\n // Wait for load/error so we only mark success on actual load and allow\n // retry after error — inserting without waiting would mark success\n // immediately even on failure. (Bug: Takazudo/zudo-front-builder#896)\n // Some browsers (e.g. Safari) never fire load/error on rel=prefetch\n // links; without the settle timeout the href would sit in inFlight\n // forever and block every future retry. Timing out counts as\n // success — the resource was requested, which is all prefetch needs.\n await new Promise<void>((resolve, reject) => {\n const timer = setTimeout(() => resolve(), LINK_SETTLE_TIMEOUT_MS);\n link.addEventListener(\n \"load\",\n () => {\n clearTimeout(timer);\n resolve();\n },\n { once: true },\n );\n link.addEventListener(\n \"error\",\n () => {\n clearTimeout(timer);\n // Remove the failed element so retries don't accumulate\n // dead <link> nodes in <head>.\n link.remove();\n reject(new Error(`prefetch link error: ${href}`));\n },\n { once: true },\n );\n document.head.appendChild(link);\n });\n } else {\n await fetch(href, { priority: \"low\" } as RequestInit & { priority?: string });\n }\n prefetched.add(href);\n } finally {\n inFlight.delete(href);\n }\n })();\n\n inFlight.set(href, p);\n return p;\n}\n\n/**\n * Public prefetch function. Idempotent per href.\n */\nexport function prefetch(url: string, opts: PrefetchOptions = {}): void {\n const href = resolveHref(url);\n if (!href) return; // cross-origin — skip\n\n if (opts.ignoreSlowConnection !== true && isSlowConnection()) return;\n\n if (prefetched.has(href) || inFlight.has(href)) return;\n\n executePrefetch(href, opts).catch(() => {});\n}\n\n// ---------------------------------------------------------------------------\n// Shared enter/leave handler factory\n// ---------------------------------------------------------------------------\n\ntype CancelHandleMap = Map<Element, ReturnType<typeof setTimeout> | number>;\n\n/**\n * Returns a pair of [enterHandler, leaveHandler] that queue and cancel an idle\n * prefetch callback, keyed on the supplied per-trigger cancel-handle map.\n *\n * Both the pointer (hover) and focus triggers use identical queue/cancel logic;\n * the only difference between them is which map tracks their pending handles.\n * Parameterising by the map eliminates that duplication.\n */\nfunction makeEnterLeaveHandlers(\n cancelHandles: CancelHandleMap,\n): [enterHandler: (e: Event) => void, leaveHandler: (e: Event) => void] {\n function enterHandler(e: Event): void {\n const link = (e.target as Element).closest(\"a[href]\") as HTMLAnchorElement | null;\n if (!link) return;\n if (!shouldPrefetchLink(link, \"hover\")) return;\n\n // Use requestIdleCallback if available, else a small timeout.\n const fire = () => {\n cancelHandles.delete(link);\n prefetch(link.href);\n };\n\n if (typeof requestIdleCallback !== \"undefined\") {\n const handle = requestIdleCallback(fire);\n cancelHandles.set(link, handle);\n } else {\n const handle = setTimeout(fire, 100);\n cancelHandles.set(link, handle);\n }\n }\n\n function leaveHandler(e: Event): void {\n const link = (e.target as Element).closest(\"a[href]\") as HTMLAnchorElement | null;\n if (!link) return;\n\n const handle = cancelHandles.get(link);\n if (handle === undefined) return;\n\n if (typeof cancelIdleCallback !== \"undefined\") {\n cancelIdleCallback(handle as number);\n } else {\n clearTimeout(handle as ReturnType<typeof setTimeout>);\n }\n cancelHandles.delete(link);\n }\n\n return [enterHandler, leaveHandler];\n}\n\n// ---------------------------------------------------------------------------\n// Trigger: hover (pointerenter / pointerleave)\n// ---------------------------------------------------------------------------\n\nconst [onPointerEnter, onPointerLeave] = makeEnterLeaveHandlers(hoverCancelHandles);\n\n// ---------------------------------------------------------------------------\n// Trigger: focus (focusin / focusout with cancel on focusout)\n// ---------------------------------------------------------------------------\n\nconst [onFocusIn, onFocusOut] = makeEnterLeaveHandlers(focusCancelHandles);\n\n// ---------------------------------------------------------------------------\n// Trigger: tap (touchstart / mousedown)\n// ---------------------------------------------------------------------------\n\nfunction onTap(e: Event): void {\n const link = (e.target as Element).closest(\"a[href]\") as HTMLAnchorElement | null;\n if (!link) return;\n if (!shouldPrefetchLink(link, \"tap\")) return;\n prefetch(link.href);\n}\n\n// ---------------------------------------------------------------------------\n// Trigger: viewport (IntersectionObserver)\n// ---------------------------------------------------------------------------\n\nfunction initViewportObserver(): IntersectionObserver {\n if (viewportObserver) return viewportObserver;\n\n viewportObserver = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n const link = entry.target as HTMLAnchorElement;\n prefetch(link.href);\n // Once observed and in-flight, no need to keep observing.\n viewportObserver?.unobserve(link);\n }\n }\n },\n { threshold: 0 },\n );\n\n return viewportObserver;\n}\n\nfunction observeViewportLinks(): void {\n const observer = initViewportObserver();\n const selector =\n prefetchAll && defaultStrategy === \"viewport\"\n ? \"a[href]:not([data-zfb-prefetch='false'])\"\n : \"a[data-zfb-prefetch='viewport']\";\n\n document.querySelectorAll<HTMLAnchorElement>(selector).forEach((link) => {\n const href = resolveHref(link.href);\n if (href && !prefetched.has(href)) {\n observer.observe(link);\n }\n });\n}\n\n// ---------------------------------------------------------------------------\n// Trigger: load (requestIdleCallback after DOMContentLoaded)\n// ---------------------------------------------------------------------------\n\nfunction prefetchLoadLinks(): void {\n const selector =\n prefetchAll && defaultStrategy === \"load\"\n ? \"a[href]:not([data-zfb-prefetch='false'])\"\n : \"a[data-zfb-prefetch='load']\";\n\n document.querySelectorAll<HTMLAnchorElement>(selector).forEach((link) => {\n const href = resolveHref(link.href);\n if (!href || prefetched.has(href)) return;\n\n if (typeof requestIdleCallback !== \"undefined\") {\n requestIdleCallback(() => prefetch(link.href));\n } else {\n setTimeout(() => prefetch(link.href), 0);\n }\n });\n}\n\n// ---------------------------------------------------------------------------\n// Per-link strategy resolution\n// ---------------------------------------------------------------------------\n\n/**\n * Determine whether a given link should be prefetched for the supplied trigger strategy.\n * Returns false if:\n * - data-zfb-prefetch=\"false\" (always disabled)\n * - The effective strategy for this link doesn't match the active trigger\n */\nfunction shouldPrefetchLink(link: HTMLAnchorElement, triggerStrategy: PrefetchStrategy): boolean {\n const attr = link.dataset[\"zfbPrefetch\"];\n\n // Explicitly disabled.\n if (attr === \"false\") return false;\n\n // Per-link explicit strategy.\n if (attr && attr !== \"false\") {\n return attr === triggerStrategy;\n }\n\n // No explicit attribute — rely on prefetchAll + defaultStrategy.\n if (!prefetchAll) return false;\n\n return defaultStrategy === triggerStrategy;\n}\n\n// ---------------------------------------------------------------------------\n// Post-swap re-scan\n// ---------------------------------------------------------------------------\n\nfunction onAfterSwap(): void {\n // Disconnect the existing observer before re-scanning so detached anchors from\n // the old body are unobserved and don't accumulate across SPA swaps.\n // (Bug: Takazudo/zudo-front-builder#896 — observer leak across SPA swaps)\n if (viewportObserver) {\n viewportObserver.disconnect();\n viewportObserver = null;\n }\n observeViewportLinks();\n prefetchLoadLinks();\n}\n\n// ---------------------------------------------------------------------------\n// init()\n// ---------------------------------------------------------------------------\n\n/**\n * Initialize the prefetch module.\n *\n * Idempotent — multiple calls are safe. The module-level `initialized` flag\n * ensures listeners are registered exactly once.\n *\n * DISABLED-FLAG CONTRACT: if `<meta name=\"zfb-prefetch-disabled\" content=\"true\">`\n * is present in the document at init() time, this function is a no-op.\n */\nexport function init(options?: PrefetchInitOptions): void {\n // Disabled-flag check — exact selector is the contract with #272-config.\n if (document.querySelector('meta[name=\"zfb-prefetch-disabled\"][content=\"true\"]')) {\n return;\n }\n\n if (initialized) return;\n initialized = true;\n\n prefetchAll = options?.prefetchAll ?? false;\n defaultStrategy = options?.defaultStrategy ?? \"hover\";\n\n // Hover + tap — document delegation, picks up SPA-inserted links automatically.\n document.addEventListener(\"pointerenter\", onPointerEnter, { capture: true });\n document.addEventListener(\"pointerleave\", onPointerLeave, { capture: true });\n // Focus — separate cancel map so hover and focus don't share handles.\n document.addEventListener(\"focusin\", onFocusIn, { capture: true });\n document.addEventListener(\"focusout\", onFocusOut, { capture: true });\n document.addEventListener(\"touchstart\", onTap, { capture: true, passive: true });\n document.addEventListener(\"mousedown\", onTap, { capture: true });\n\n // Viewport — observe current links immediately.\n observeViewportLinks();\n\n // Load — queue current links via idle callback.\n const queueLoad = () => prefetchLoadLinks();\n if (document.readyState === \"loading\") {\n document.addEventListener(\"DOMContentLoaded\", queueLoad, { once: true });\n } else {\n queueLoad();\n }\n\n // Post-swap re-scan — re-walk viewport + load links after each SPA navigation.\n document.addEventListener(\"zfb:after-swap\", onAfterSwap);\n}\n"]}
1
+ {"version":3,"file":"prefetch.js","sourceRoot":"","sources":["../../src/client-router/prefetch.ts"],"names":[],"mappings":"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","sourcesContent":["/// <reference lib=\"dom\" />\n/// <reference lib=\"dom.iterable\" />\n// `@takazudo/zfb-runtime` — prefetch module.\n//\n// Ported from Astro's prefetch module (packages/astro/src/prefetch/index.ts).\n// Source: https://github.com/withastro/astro\n// Issue: Takazudo/zudo-front-builder#276\n//\n// Mechanical renames per W1B §13.5:\n// astro:* event names → zfb:*\n// data-astro-prefetch → data-zfb-prefetch\n// __PREFETCH_DISABLED__ → <meta name=\"zfb-prefetch-disabled\" content=\"true\">\n//\n// DISABLED-FLAG CONTRACT — exact meta tag name and content value are locked:\n// meta[name=\"zfb-prefetch-disabled\"][content=\"true\"]\n// Both this module and sibling sub-issue #272-config pin this verbatim.\n\nexport type PrefetchStrategy = \"hover\" | \"viewport\" | \"load\" | \"tap\";\n\nexport interface PrefetchInitOptions {\n prefetchAll?: boolean;\n defaultStrategy?: PrefetchStrategy;\n}\n\nexport interface PrefetchOptions {\n ignoreSlowConnection?: boolean;\n with?: \"link\" | \"fetch\";\n}\n\n// Module-level state — persists across SPA navigations within a session.\nlet initialized = false;\nlet prefetchAll = false;\nlet defaultStrategy: PrefetchStrategy = \"hover\";\n\n// Set of hrefs that have been successfully prefetched.\nconst prefetched = new Set<string>();\n// Map of in-flight prefetch promises for dedup (also used as the concurrent short-circuit guard).\nconst inFlight = new Map<string, Promise<void>>();\n\n// How long to wait for a <link rel=prefetch>'s load/error before treating\n// the prefetch as settled anyway (see executePrefetch).\nconst LINK_SETTLE_TIMEOUT_MS = 10_000;\n\n// The viewport observer — retained across post-swap re-scans so new elements\n// can be observed without recreating the observer.\nlet viewportObserver: IntersectionObserver | null = null;\n\n// Hover cancel handle — set when a pointerenter idle-callback fires, cleared on\n// pointerleave before the callback executes.\nconst hoverCancelHandles = new Map<Element, ReturnType<typeof setTimeout> | number>();\n\n// Focus cancel handle — set when a focusin idle-callback fires, cleared on\n// focusout before the callback executes.\nconst focusCancelHandles = new Map<Element, ReturnType<typeof setTimeout> | number>();\n\n/**\n * Reset all module-level state. Exported for test isolation only — not part of\n * the public API and not re-exported from any barrel.\n */\nexport function __resetForTests(): void {\n initialized = false;\n prefetchAll = false;\n defaultStrategy = \"hover\";\n prefetched.clear();\n inFlight.clear();\n if (viewportObserver) {\n viewportObserver.disconnect();\n viewportObserver = null;\n }\n hoverCancelHandles.clear();\n focusCancelHandles.clear();\n}\n\n// ---------------------------------------------------------------------------\n// Feature detection\n// ---------------------------------------------------------------------------\n\nfunction supportsLinkPrefetch(): boolean {\n const link = document.createElement(\"link\");\n return link.relList?.supports?.(\"prefetch\") ?? false;\n}\n\nfunction isSlowConnection(): boolean {\n const nav = navigator as Navigator & {\n connection?: { saveData?: boolean; effectiveType?: string };\n };\n return (\n nav.connection?.saveData === true ||\n nav.connection?.effectiveType === \"2g\" ||\n nav.connection?.effectiveType === \"slow-2g\"\n );\n}\n\n// ---------------------------------------------------------------------------\n// Core prefetch logic\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve an href to an absolute URL string keyed against the current origin.\n * Returns null for cross-origin hrefs (these are skipped entirely).\n */\nfunction resolveHref(href: string): string | null {\n try {\n const url = new URL(href, location.href);\n if (url.origin !== location.origin) return null;\n return url.href;\n } catch {\n return null;\n }\n}\n\n/**\n * Execute a single prefetch for the given absolute href.\n * Uses <link rel=\"prefetch\"> (preferred) or fetch() fallback.\n */\nfunction executePrefetch(href: string, opts: PrefetchOptions): Promise<void> {\n const existing = inFlight.get(href);\n if (existing) return existing;\n\n const p: Promise<void> = (async () => {\n try {\n const method = opts.with ?? (supportsLinkPrefetch() ? \"link\" : \"fetch\");\n if (method === \"link\") {\n const link = document.createElement(\"link\");\n link.rel = \"prefetch\";\n link.href = href;\n // Wait for load/error so we only mark success on actual load and allow\n // retry after error — inserting without waiting would mark success\n // immediately even on failure. (Bug: Takazudo/zudo-front-builder#896)\n // Some browsers (e.g. Safari) never fire load/error on rel=prefetch\n // links; without the settle timeout the href would sit in inFlight\n // forever and block every future retry. Timing out counts as\n // success — the resource was requested, which is all prefetch needs.\n await new Promise<void>((resolve, reject) => {\n const timer = setTimeout(() => resolve(), LINK_SETTLE_TIMEOUT_MS);\n link.addEventListener(\n \"load\",\n () => {\n clearTimeout(timer);\n resolve();\n },\n { once: true },\n );\n link.addEventListener(\n \"error\",\n () => {\n clearTimeout(timer);\n // Remove the failed element so retries don't accumulate\n // dead <link> nodes in <head>.\n link.remove();\n reject(new Error(`prefetch link error: ${href}`));\n },\n { once: true },\n );\n document.head.appendChild(link);\n });\n } else {\n await fetch(href, { priority: \"low\" } as RequestInit & { priority?: string });\n }\n prefetched.add(href);\n } finally {\n inFlight.delete(href);\n }\n })();\n\n inFlight.set(href, p);\n return p;\n}\n\n/**\n * Public prefetch function. Idempotent per href.\n */\nexport function prefetch(url: string, opts: PrefetchOptions = {}): void {\n const href = resolveHref(url);\n if (!href) return; // cross-origin — skip\n\n if (opts.ignoreSlowConnection !== true && isSlowConnection()) return;\n\n if (prefetched.has(href) || inFlight.has(href)) return;\n\n executePrefetch(href, opts).catch(() => {});\n}\n\n// ---------------------------------------------------------------------------\n// Shared enter/leave handler factory\n// ---------------------------------------------------------------------------\n\ntype CancelHandleMap = Map<Element, ReturnType<typeof setTimeout> | number>;\n\n/**\n * Returns a pair of [enterHandler, leaveHandler] that queue and cancel an idle\n * prefetch callback, keyed on the supplied per-trigger cancel-handle map.\n *\n * Both the pointer (hover) and focus triggers use identical queue/cancel logic;\n * the only difference between them is which map tracks their pending handles.\n * Parameterising by the map eliminates that duplication.\n */\n// pointerenter/pointerleave (and focusin/focusout) fire on every element the\n// pointer/focus enters or leaves, including nested descendants — not just the\n// delegated document listener's eventual `closest(\"a[href]\")` target. For\n// `<a><span>text</span></a>`, moving the pointer between the link's own\n// children fires a leave on the child being left and an enter on the child\n// being entered, even though the pointer never left the LINK itself.\n// `relatedTarget` is where the pointer/focus is going (leave) or came from\n// (enter); reading it lets the two handlers below tell an intra-link move\n// apart from a real leave/first-enter. #1398.\ntype RelatedTargetEvent = Event & { relatedTarget?: EventTarget | null };\n\nfunction makeEnterLeaveHandlers(\n cancelHandles: CancelHandleMap,\n): [enterHandler: (e: Event) => void, leaveHandler: (e: Event) => void] {\n function enterHandler(e: Event): void {\n const link = (e.target as Element).closest(\"a[href]\") as HTMLAnchorElement | null;\n if (!link) return;\n if (!shouldPrefetchLink(link, \"hover\")) return;\n\n // A handle is already queued for this link — the pointer/focus moved\n // between nested descendants of the SAME link, which re-fires enter on\n // each one. Keep the original debounce window instead of overwriting it\n // with a fresh requestIdleCallback/timeout that a busy pointer could keep\n // resetting indefinitely. #1398.\n if (cancelHandles.has(link)) return;\n\n // The pending debounce already FIRED for this link — its idle callback ran\n // prefetch(link.href), so the handle is gone from cancelHandles but the\n // href is now prefetched (or in-flight). A later enter on the SAME link\n // (e.g. an intra-link child->child move whose first callback fired in the\n // gap between the two pointer moves — which the synchronous-dispatch L2\n // suite cannot reproduce but a real browser does) must NOT queue a second,\n // redundant idle callback whose prefetch() would only no-op. This is the\n // fire+requeue companion to the cancel+requeue guard above. A failed\n // prefetch leaves the href in NEITHER set (executePrefetch deletes it from\n // inFlight without adding it to prefetched), so retry-after-error still\n // works. #1398.\n const resolvedHref = resolveHref(link.href);\n if (resolvedHref && (prefetched.has(resolvedHref) || inFlight.has(resolvedHref))) return;\n\n // Use requestIdleCallback if available, else a small timeout.\n const fire = () => {\n cancelHandles.delete(link);\n prefetch(link.href);\n };\n\n if (typeof requestIdleCallback !== \"undefined\") {\n const handle = requestIdleCallback(fire);\n cancelHandles.set(link, handle);\n } else {\n const handle = setTimeout(fire, 100);\n cancelHandles.set(link, handle);\n }\n }\n\n function leaveHandler(e: Event): void {\n const link = (e.target as Element).closest(\"a[href]\") as HTMLAnchorElement | null;\n if (!link) return;\n\n // The pointer/focus is moving to relatedTarget. If that's still inside\n // the same link (a nested descendant), this is NOT a real leave — skip\n // the cancel so the pending prefetch survives intra-link moves. #1398.\n const related = (e as RelatedTargetEvent).relatedTarget;\n if (related instanceof Node && link.contains(related)) return;\n\n const handle = cancelHandles.get(link);\n if (handle === undefined) return;\n\n if (typeof cancelIdleCallback !== \"undefined\") {\n cancelIdleCallback(handle as number);\n } else {\n clearTimeout(handle as ReturnType<typeof setTimeout>);\n }\n cancelHandles.delete(link);\n }\n\n return [enterHandler, leaveHandler];\n}\n\n// ---------------------------------------------------------------------------\n// Trigger: hover (pointerenter / pointerleave)\n// ---------------------------------------------------------------------------\n\nconst [onPointerEnter, onPointerLeave] = makeEnterLeaveHandlers(hoverCancelHandles);\n\n// ---------------------------------------------------------------------------\n// Trigger: focus (focusin / focusout with cancel on focusout)\n// ---------------------------------------------------------------------------\n\nconst [onFocusIn, onFocusOut] = makeEnterLeaveHandlers(focusCancelHandles);\n\n// ---------------------------------------------------------------------------\n// Trigger: tap (touchstart / mousedown)\n// ---------------------------------------------------------------------------\n\nfunction onTap(e: Event): void {\n const link = (e.target as Element).closest(\"a[href]\") as HTMLAnchorElement | null;\n if (!link) return;\n if (!shouldPrefetchLink(link, \"tap\")) return;\n prefetch(link.href);\n}\n\n// ---------------------------------------------------------------------------\n// Trigger: viewport (IntersectionObserver)\n// ---------------------------------------------------------------------------\n\nfunction initViewportObserver(): IntersectionObserver {\n if (viewportObserver) return viewportObserver;\n\n viewportObserver = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n const link = entry.target as HTMLAnchorElement;\n prefetch(link.href);\n // Once observed and in-flight, no need to keep observing.\n viewportObserver?.unobserve(link);\n }\n }\n },\n { threshold: 0 },\n );\n\n return viewportObserver;\n}\n\nfunction observeViewportLinks(): void {\n const observer = initViewportObserver();\n const selector =\n prefetchAll && defaultStrategy === \"viewport\"\n ? \"a[href]:not([data-zfb-prefetch='false'])\"\n : \"a[data-zfb-prefetch='viewport']\";\n\n document.querySelectorAll<HTMLAnchorElement>(selector).forEach((link) => {\n const href = resolveHref(link.href);\n if (href && !prefetched.has(href)) {\n observer.observe(link);\n }\n });\n}\n\n// ---------------------------------------------------------------------------\n// Trigger: load (requestIdleCallback after DOMContentLoaded)\n// ---------------------------------------------------------------------------\n\nfunction prefetchLoadLinks(): void {\n const selector =\n prefetchAll && defaultStrategy === \"load\"\n ? \"a[href]:not([data-zfb-prefetch='false'])\"\n : \"a[data-zfb-prefetch='load']\";\n\n document.querySelectorAll<HTMLAnchorElement>(selector).forEach((link) => {\n const href = resolveHref(link.href);\n if (!href || prefetched.has(href)) return;\n\n if (typeof requestIdleCallback !== \"undefined\") {\n requestIdleCallback(() => prefetch(link.href));\n } else {\n setTimeout(() => prefetch(link.href), 0);\n }\n });\n}\n\n// ---------------------------------------------------------------------------\n// Per-link strategy resolution\n// ---------------------------------------------------------------------------\n\n/**\n * Determine whether a given link should be prefetched for the supplied trigger strategy.\n * Returns false if:\n * - data-zfb-prefetch=\"false\" (always disabled)\n * - The effective strategy for this link doesn't match the active trigger\n */\nfunction shouldPrefetchLink(link: HTMLAnchorElement, triggerStrategy: PrefetchStrategy): boolean {\n const attr = link.dataset[\"zfbPrefetch\"];\n\n // Explicitly disabled.\n if (attr === \"false\") return false;\n\n // Per-link explicit strategy.\n if (attr && attr !== \"false\") {\n return attr === triggerStrategy;\n }\n\n // No explicit attribute — rely on prefetchAll + defaultStrategy.\n if (!prefetchAll) return false;\n\n return defaultStrategy === triggerStrategy;\n}\n\n// ---------------------------------------------------------------------------\n// Post-swap re-scan\n// ---------------------------------------------------------------------------\n\nfunction onAfterSwap(): void {\n // Disconnect the existing observer before re-scanning so detached anchors from\n // the old body are unobserved and don't accumulate across SPA swaps.\n // (Bug: Takazudo/zudo-front-builder#896 — observer leak across SPA swaps)\n if (viewportObserver) {\n viewportObserver.disconnect();\n viewportObserver = null;\n }\n observeViewportLinks();\n prefetchLoadLinks();\n}\n\n// ---------------------------------------------------------------------------\n// init()\n// ---------------------------------------------------------------------------\n\n/**\n * Initialize the prefetch module.\n *\n * Idempotent — multiple calls are safe. The module-level `initialized` flag\n * ensures listeners are registered exactly once.\n *\n * DISABLED-FLAG CONTRACT: if `<meta name=\"zfb-prefetch-disabled\" content=\"true\">`\n * is present in the document at init() time, this function is a no-op.\n */\nexport function init(options?: PrefetchInitOptions): void {\n // Disabled-flag check — exact selector is the contract with #272-config.\n if (document.querySelector('meta[name=\"zfb-prefetch-disabled\"][content=\"true\"]')) {\n return;\n }\n\n if (initialized) return;\n initialized = true;\n\n prefetchAll = options?.prefetchAll ?? false;\n defaultStrategy = options?.defaultStrategy ?? \"hover\";\n\n // Hover + tap — document delegation, picks up SPA-inserted links automatically.\n document.addEventListener(\"pointerenter\", onPointerEnter, { capture: true });\n document.addEventListener(\"pointerleave\", onPointerLeave, { capture: true });\n // Focus — separate cancel map so hover and focus don't share handles.\n document.addEventListener(\"focusin\", onFocusIn, { capture: true });\n document.addEventListener(\"focusout\", onFocusOut, { capture: true });\n document.addEventListener(\"touchstart\", onTap, { capture: true, passive: true });\n document.addEventListener(\"mousedown\", onTap, { capture: true });\n\n // Viewport — observe current links immediately.\n observeViewportLinks();\n\n // Load — queue current links via idle callback.\n const queueLoad = () => prefetchLoadLinks();\n if (document.readyState === \"loading\") {\n document.addEventListener(\"DOMContentLoaded\", queueLoad, { once: true });\n } else {\n queueLoad();\n }\n\n // Post-swap re-scan — re-walk viewport + load links after each SPA navigation.\n document.addEventListener(\"zfb:after-swap\", onAfterSwap);\n}\n"]}
@@ -2,6 +2,32 @@ import type { Fallback, Options, SyncHistoryEntryOptions } from "./types.js";
2
2
  export declare const supportsViewTransitions: boolean;
3
3
  export declare const transitionEnabledOnThisPage: () => boolean;
4
4
  export declare function getFallback(): Fallback;
5
+ /**
6
+ * Write a router-managed history entry (push or replace) WITHOUT any
7
+ * navigation, DOM, or lifecycle side effect. This is the supported path for
8
+ * consumers deep-linking transient UI state (dialogs/modals, a photo
9
+ * viewer's `/photos/<slug>/` URL). Hand-rolled raw `history.pushState`
10
+ * desyncs `originalLocation` and the index bookkeeping that popstate
11
+ * direction detection ({@link derivePopDirection}) and the same-page
12
+ * traverse fast-path depend on; `navigate()` can't be used instead because
13
+ * it forces a fetch. See #1377 / #1374.
14
+ *
15
+ * Never scrolls the viewport itself — but the entry it writes IS stamped
16
+ * with the CURRENT scroll position (`scrollX`/`scrollY` at call time), not
17
+ * `(0, 0)` (issue #1398). This matters only for a later Forward-traversal
18
+ * back to this entry: the same-page traverse fast-path restores whatever
19
+ * scroll position was stamped on the entry, so a same-page push (e.g.
20
+ * opening a dialog) does not snap the underlying page to the top when the
21
+ * dialog is later reopened via Forward.
22
+ *
23
+ * @param url - The URL to write. Cross-origin URLs throw rather than
24
+ * silently falling back to a full-page load.
25
+ * @param options.replace - Use `history.replaceState` instead of
26
+ * `history.pushState` (no new Back-button entry). Default: push.
27
+ * @param options.state - Merged into the entry's `history.state`; the
28
+ * router's own bookkeeping keys (`index`, `scrollX`, `scrollY`) always win
29
+ * on a colliding key.
30
+ */
5
31
  export declare function syncHistoryEntry(url: string | URL, options?: SyncHistoryEntryOptions): void;
6
32
  export declare function navigate(href: string, options?: Options): Promise<void>;
7
33
  export interface InitOptions {
@@ -131,6 +131,13 @@ const announce = () => {
131
131
  }, 60);
132
132
  };
133
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
+ };
134
141
  const DIRECTION_ATTR = "data-zfb-transition";
135
142
  const OLD_NEW_ATTR = "data-zfb-transition-fallback";
136
143
  let parser;
@@ -243,13 +250,22 @@ function runScripts() {
243
250
  }
244
251
  // Add a new entry to the browser history. This also sets the new page in the browser address bar.
245
252
  // Sets the scroll position according to the hash fragment of the new location.
246
- 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) => {
247
263
  const intraPage = samePage(from, to);
248
264
  const targetPageTitle = document.title;
249
265
  document.title = pageTitleForBrowserHistory;
250
266
  let scrolledToTop = false;
251
267
  if (to.href !== location.href && !historyState) {
252
- if (options.history === "replace") {
268
+ if (options.history === "replace" || historyCommittedEarly) {
253
269
  // Astro reads current.index/scrollX/scrollY directly; `history.state` can be
254
270
  // null (page entered without a transition state), which would throw a
255
271
  // TypeError. Fall back to a synthesized state from the tracked index/scroll.
@@ -310,13 +326,32 @@ const moveToLocation = (to, from, options, pageTitleForBrowserHistory, historySt
310
326
  }
311
327
  };
312
328
  let syncHistoryEntryOnServerWarned = false;
313
- // Public: write a router-managed history entry (push or replace) WITHOUT any
314
- // navigation, DOM, scroll, or lifecycle side effect. This is the supported path
315
- // for consumers deep-linking transient UI state (dialogs/modals, a photo
316
- // viewer's /photos/<slug>/ URL). Hand-rolled raw history.pushState desyncs
317
- // `originalLocation` and the index bookkeeping that popstate direction detection
318
- // (derivePopDirection) and the same-page traverse fast-path depend on; navigate()
319
- // can't be used because it forces a fetch. See #1377 / #1374.
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
+ */
320
355
  export function syncHistoryEntry(url, options = {}) {
321
356
  // SSR guard: no window/history to touch. Mirror navigate()'s server no-op +
322
357
  // one-time console warning. Checked at call time via `typeof document` (not the
@@ -359,7 +394,14 @@ export function syncHistoryEntry(url, options = {}) {
359
394
  // later Back restoration can't lose scroll that `scrollend` hasn't flushed
360
395
  // yet — the same guard transition() applies before a non-traverse push.
361
396
  updateScrollPosition({ scrollX, scrollY });
362
- history.pushState({ ...options.state, index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 }, "", to.href);
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);
363
405
  }
364
406
  // Always re-point originalLocation so the next transition()/onPopState uses the
365
407
  // correct "from" URL. Skipping this is exactly what makes a raw pushState
@@ -369,12 +411,25 @@ export function syncHistoryEntry(url, options = {}) {
369
411
  function preloadStyleLinks(newDocument) {
370
412
  const links = [];
371
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);
372
427
  // Do not preload links that are already on the page.
373
- if (!document.querySelector(`[${PERSIST_ATTR}="${el.getAttribute(PERSIST_ATTR)}"], link[rel=stylesheet][href="${el.getAttribute("href")}"]`)) {
428
+ if (href !== null && !existingLink) {
374
429
  const c = document.createElement("link");
375
430
  c.setAttribute("rel", "preload");
376
431
  c.setAttribute("as", "style");
377
- c.setAttribute("href", el.getAttribute("href"));
432
+ c.setAttribute("href", href);
378
433
  links.push(new Promise((resolve) => {
379
434
  ["load", "error"].forEach((evName) => c.addEventListener(evName, resolve));
380
435
  document.head.append(c);
@@ -387,7 +442,9 @@ function preloadStyleLinks(newDocument) {
387
442
  // if !popstate, update the history entry and scroll position according to toLocation
388
443
  // if popState is given, this holds the scroll position for history navigation
389
444
  // if fallback === "animate" then simulate view transitions
390
- 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) {
391
448
  async function animate(phase) {
392
449
  function isInfinite(animation) {
393
450
  const effect = animation.effect;
@@ -427,9 +484,15 @@ async function updateDOM(preparationEvent, options, currentTransition, historySt
427
484
  // trees receive render(null, element) / root.unmount() and their useEffect
428
485
  // cleanups fire. Must happen after cancelPendingIslands() and before doSwap()
429
486
  // so document.body still points to the old body.
430
- 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);
431
494
  const swapEvent = await doSwap(preparationEvent, currentTransition.viewTransition, animateFallbackOld);
432
- moveToLocation(swapEvent.to, swapEvent.from, options, pageTitleForBrowserHistory, historyState);
495
+ moveToLocation(swapEvent.to, swapEvent.from, options, pageTitleForBrowserHistory, historyState, historyCommittedEarly);
433
496
  triggerEvent("zfb:after-swap");
434
497
  // Resolve the finished promise of the simulation's ViewTransition.
435
498
  // For 'animate', wait for the new-page animation to complete first.
@@ -614,7 +677,13 @@ async function transition(direction, from, to, options, historyState, hasUAVisua
614
677
  //
615
678
  // Traverse (popstate) navigations carry historyState: the browser has already
616
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;
617
685
  if (!historyState && prepEvent.to.href !== location.href) {
686
+ historyCommittedEarly = true;
618
687
  if (options.history === "replace") {
619
688
  // Mirror of the moveToLocation replace-path guard: `history.state` can be
620
689
  // null here too (page entered without a transition state), so synthesize a
@@ -638,7 +707,7 @@ async function transition(direction, from, to, options, historyState, hasUAVisua
638
707
  if (supportsViewTransitions && !hasUAVisualTransition) {
639
708
  // This automatically cancels any previous transition
640
709
  // We also already took care that the earlier update callback got through
641
- 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));
642
711
  }
643
712
  else {
644
713
  // Simulation mode requires a bit more manual work.
@@ -648,7 +717,7 @@ async function transition(direction, from, to, options, historyState, hasUAVisua
648
717
  const updateDone = (async () => {
649
718
  // Immediately paused to set up the ViewTransition object for Fallback mode
650
719
  await Promise.resolve(); // hop through the micro task queue
651
- await updateDOM(prepEvent, options, currentTransition, historyState, hasUAVisualTransition ? "swap" : getFallback());
720
+ await updateDOM(prepEvent, options, currentTransition, historyState, hasUAVisualTransition ? "swap" : getFallback(), historyCommittedEarly);
652
721
  return undefined;
653
722
  })();
654
723
  // When the updateDone promise is settled,