@takazudo/zfb-runtime 0.1.0-next.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +237 -0
- package/dist/client-router/cssesc.d.ts +9 -0
- package/dist/client-router/cssesc.d.ts.map +1 -0
- package/dist/client-router/cssesc.js +95 -0
- package/dist/client-router/cssesc.js.map +1 -0
- package/dist/client-router/events.d.ts +42 -0
- package/dist/client-router/events.d.ts.map +1 -0
- package/dist/client-router/events.js +114 -0
- package/dist/client-router/events.js.map +1 -0
- package/dist/client-router/index.d.ts +9 -0
- package/dist/client-router/index.d.ts.map +1 -0
- package/dist/client-router/index.js +18 -0
- package/dist/client-router/index.js.map +1 -0
- package/dist/client-router/prefetch.d.ts +29 -0
- package/dist/client-router/prefetch.d.ts.map +1 -0
- package/dist/client-router/prefetch.js +288 -0
- package/dist/client-router/prefetch.js.map +1 -0
- package/dist/client-router/router.d.ts +17 -0
- package/dist/client-router/router.d.ts.map +1 -0
- package/dist/client-router/router.js +739 -0
- package/dist/client-router/router.js.map +1 -0
- package/dist/client-router/swap-functions.d.ts +22 -0
- package/dist/client-router/swap-functions.d.ts.map +1 -0
- package/dist/client-router/swap-functions.js +252 -0
- package/dist/client-router/swap-functions.js.map +1 -0
- package/dist/client-router/types.d.ts +11 -0
- package/dist/client-router/types.d.ts.map +1 -0
- package/dist/client-router/types.js +3 -0
- package/dist/client-router/types.js.map +1 -0
- package/dist/client-router.d.ts +36 -0
- package/dist/client-router.d.ts.map +1 -0
- package/dist/client-router.js +117 -0
- package/dist/client-router.js.map +1 -0
- package/dist/framework.d.ts +17 -0
- package/dist/framework.d.ts.map +1 -0
- package/dist/framework.js +17 -0
- package/dist/framework.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/router.d.ts +97 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +318 -0
- package/dist/router.js.map +1 -0
- package/dist/snapshot.d.ts +38 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +16 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/view-transitions.d.ts +34 -0
- package/dist/view-transitions.d.ts.map +1 -0
- package/dist/view-transitions.js +54 -0
- package/dist/view-transitions.js.map +1 -0
- package/package.json +78 -0
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/// <reference lib="dom.iterable" />
|
|
3
|
+
// Ported from Astro transitions/router.ts (transition orchestration half).
|
|
4
|
+
// Source: https://raw.githubusercontent.com/withastro/astro/main/packages/astro/src/transitions/router.ts
|
|
5
|
+
// Issue: zudolab/zudo-doc#1516 (W3C1), parent epic zudolab/zudo-doc#1510.
|
|
6
|
+
//
|
|
7
|
+
// Mechanical renames per W1B §13.5:
|
|
8
|
+
// astro:* event names → zfb:*
|
|
9
|
+
// data-astro-* attributes → data-zfb-*
|
|
10
|
+
// astro-view-transitions-* → zfb-view-transitions-*
|
|
11
|
+
// .astro-route-announcer → .zfb-route-announcer
|
|
12
|
+
// dataset.astroExec → dataset.zfbExec
|
|
13
|
+
// dataset.astroHistory → dataset.zfbHistory
|
|
14
|
+
// dataset.astroRerun → dataset.zfbRerun
|
|
15
|
+
//
|
|
16
|
+
// Named-cause deviations:
|
|
17
|
+
// - `internalFetchHeaders` import dropped; replaced with `{}` (W1B §13.5 — zfb adapters
|
|
18
|
+
// do not currently expose a per-fetch internal-headers contract).
|
|
19
|
+
// - `prepareForClientOnlyComponents` dropped entirely (W1B §13.5 / W3C2 — DEV-only
|
|
20
|
+
// iframe trick that compensates for Vite per-component CSS injection on hydrate;
|
|
21
|
+
// not applicable to zfb islands which inject CSS via the bundle).
|
|
22
|
+
// - `import.meta.env.SSR` access uses `(import.meta as any).env?.SSR` (no Vite
|
|
23
|
+
// ambient types in zfb-runtime tsconfig — same workaround used in W3B).
|
|
24
|
+
// - `inBrowser` evaluates to `typeof document !== "undefined"` rather than relying
|
|
25
|
+
// on the SSR flag, because the runtime package serves both server- and client-side
|
|
26
|
+
// code; same observable behavior in browser and on SSR.
|
|
27
|
+
// - `announce()` is a TODO stub (W3C3 owns the route announcer).
|
|
28
|
+
//
|
|
29
|
+
// W3C2 additions (this file):
|
|
30
|
+
// - `navigate()` public entry.
|
|
31
|
+
// - `onPopState`, `onScrollEnd`.
|
|
32
|
+
// - Top-level `if (inBrowser)` initialization block (seeds `currentHistoryIndex`
|
|
33
|
+
// from `history.state`, registers popstate / load / scrollend listeners, and
|
|
34
|
+
// marks already-executed scripts with `dataset["zfbExec"] = ""`).
|
|
35
|
+
//
|
|
36
|
+
// W3C1 deferred to W3C3:
|
|
37
|
+
// - `announce()` route-announcer implementation.
|
|
38
|
+
// - Click + form intercept.
|
|
39
|
+
import { doPreparation, doSwap, onPageLoad, triggerEvent, updateScrollPosition, } from "./events.js";
|
|
40
|
+
import { detectScriptExecuted } from "./swap-functions.js";
|
|
41
|
+
// Island re-bootstrap and deferred-cancel after body swap (W1B §12.2, §12.5).
|
|
42
|
+
// mountNewIslands() is called after runScripts() and before onPageLoad().
|
|
43
|
+
// cancelPendingIslands() is called before doSwap() so deferred callbacks
|
|
44
|
+
// (rIC/IntersectionObserver) do not fire against orphan elements.
|
|
45
|
+
import { cancelPendingIslands, mountNewIslands, unmountIslands } from "@takazudo/zfb/runtime";
|
|
46
|
+
// Adapter-specific internal fetch headers. zfb adapters do not currently expose this
|
|
47
|
+
// contract — the empty object preserves the call-shape so the loop in fetchHTML is a
|
|
48
|
+
// no-op until/unless an adapter wires this up.
|
|
49
|
+
const internalFetchHeaders = {};
|
|
50
|
+
// Detect browser context. Astro uses `import.meta.env.SSR === false` (Vite-injected).
|
|
51
|
+
// The zfb-runtime tsconfig has no Vite ambient types; checking for `document` is
|
|
52
|
+
// behaviorally identical and avoids a Vite type dependency.
|
|
53
|
+
const inBrowser = typeof document !== "undefined";
|
|
54
|
+
export const supportsViewTransitions = inBrowser && !!document.startViewTransition;
|
|
55
|
+
export const transitionEnabledOnThisPage = () => inBrowser && !!document.querySelector('[name="zfb-view-transitions-enabled"]');
|
|
56
|
+
const samePage = (thisLocation, otherLocation) => thisLocation.pathname === otherLocation.pathname && thisLocation.search === otherLocation.search;
|
|
57
|
+
// The previous navigation that might still be in processing
|
|
58
|
+
let mostRecentNavigation;
|
|
59
|
+
// The previous transition that might still be in processing
|
|
60
|
+
let mostRecentTransition;
|
|
61
|
+
// When we traverse the history, the window.location is already set to the new location.
|
|
62
|
+
// This variable tells us where we came from
|
|
63
|
+
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.
|
|
66
|
+
// The 60ms delay is Astro's magic number: screen readers need to see the element change
|
|
67
|
+
// and may miss it if it happens too quickly.
|
|
68
|
+
const announce = () => {
|
|
69
|
+
let div = document.createElement("div");
|
|
70
|
+
div.setAttribute("aria-live", "assertive");
|
|
71
|
+
div.setAttribute("aria-atomic", "true");
|
|
72
|
+
div.className = "zfb-route-announcer";
|
|
73
|
+
document.body.append(div);
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
let title = document.title || document.querySelector("h1")?.textContent || location.pathname;
|
|
76
|
+
div.textContent = title;
|
|
77
|
+
},
|
|
78
|
+
// Screen readers need to see the element change; 60ms is Astro's empirically chosen delay.
|
|
79
|
+
60);
|
|
80
|
+
};
|
|
81
|
+
const PERSIST_ATTR = "data-zfb-transition-persist";
|
|
82
|
+
const DIRECTION_ATTR = "data-zfb-transition";
|
|
83
|
+
const OLD_NEW_ATTR = "data-zfb-transition-fallback";
|
|
84
|
+
let parser;
|
|
85
|
+
// The History API does not tell you if navigation is forward or back, so
|
|
86
|
+
// you can figure it using an index. On pushState the index is incremented so you
|
|
87
|
+
// can use that to determine popstate if going forward or back.
|
|
88
|
+
let currentHistoryIndex = 0;
|
|
89
|
+
if (inBrowser) {
|
|
90
|
+
if (history.state) {
|
|
91
|
+
// Here we reloaded a page with history state
|
|
92
|
+
// (e.g. history navigation from non-transition page or browser reload)
|
|
93
|
+
currentHistoryIndex = history.state.index;
|
|
94
|
+
scrollTo({ left: history.state.scrollX, top: history.state.scrollY });
|
|
95
|
+
}
|
|
96
|
+
else if (transitionEnabledOnThisPage()) {
|
|
97
|
+
// This page is loaded from the browser address bar or via a link from extern,
|
|
98
|
+
// it needs a state in the history
|
|
99
|
+
history.replaceState({ index: currentHistoryIndex, scrollX, scrollY }, "");
|
|
100
|
+
history.scrollRestoration = "manual";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// returns the contents of the page or null if the router can't deal with it.
|
|
104
|
+
async function fetchHTML(href, init) {
|
|
105
|
+
try {
|
|
106
|
+
// Apply adapter-specific headers for internal fetches
|
|
107
|
+
const headers = new Headers(init?.headers);
|
|
108
|
+
for (const [key, value] of Object.entries(internalFetchHeaders)) {
|
|
109
|
+
headers.set(key, value);
|
|
110
|
+
}
|
|
111
|
+
const res = await fetch(href, { ...init, headers });
|
|
112
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
113
|
+
// drop potential charset (+ other name/value pairs) as parser needs the mediaType
|
|
114
|
+
const mediaType = contentType.split(";", 1)[0].trim();
|
|
115
|
+
// the DOMParser can handle two types of HTML
|
|
116
|
+
if (mediaType !== "text/html" && mediaType !== "application/xhtml+xml") {
|
|
117
|
+
// everything else (e.g. audio/mp3) will be handled by the browser but not by us
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const html = await res.text();
|
|
121
|
+
// exactOptionalPropertyTypes: true forbids assigning `undefined` to a `redirected?: string`
|
|
122
|
+
// slot, so omit the property when not redirected instead of setting it to undefined.
|
|
123
|
+
return res.redirected ? { html, redirected: res.url, mediaType } : { html, mediaType };
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// can't fetch, let someone else deal with it.
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export function getFallback() {
|
|
131
|
+
const el = document.querySelector('[name="zfb-view-transitions-fallback"]');
|
|
132
|
+
if (el) {
|
|
133
|
+
return el.getAttribute("content");
|
|
134
|
+
}
|
|
135
|
+
return "animate";
|
|
136
|
+
}
|
|
137
|
+
function runScripts() {
|
|
138
|
+
let wait = Promise.resolve();
|
|
139
|
+
let needsWaitForInlineModuleScript = false;
|
|
140
|
+
// The original code made the assumption that all inline scripts are directly executed when inserted into the DOM.
|
|
141
|
+
// This is not true for inline module scripts, which are deferred but still executed in order.
|
|
142
|
+
// inline module scripts cannot be awaited for with onload.
|
|
143
|
+
// Thus to be able to wait for the execution of all scripts, we make sure that the last inline module script
|
|
144
|
+
// is always followed by an external module script
|
|
145
|
+
for (const script of document.getElementsByTagName("script")) {
|
|
146
|
+
script.dataset["zfbExec"] === undefined &&
|
|
147
|
+
script.getAttribute("type") === "module" &&
|
|
148
|
+
(needsWaitForInlineModuleScript = script.getAttribute("src") === null);
|
|
149
|
+
}
|
|
150
|
+
needsWaitForInlineModuleScript &&
|
|
151
|
+
document.body.insertAdjacentHTML("beforeend", `<script type="module" src="data:application/javascript,"/>`);
|
|
152
|
+
for (const script of document.getElementsByTagName("script")) {
|
|
153
|
+
if (script.dataset["zfbExec"] === "")
|
|
154
|
+
continue;
|
|
155
|
+
const type = script.getAttribute("type");
|
|
156
|
+
if (type && type !== "module" && type !== "text/javascript")
|
|
157
|
+
continue;
|
|
158
|
+
const newScript = document.createElement("script");
|
|
159
|
+
newScript.innerHTML = script.innerHTML;
|
|
160
|
+
for (const attr of script.attributes) {
|
|
161
|
+
if (attr.name === "src") {
|
|
162
|
+
const p = new Promise((r) => {
|
|
163
|
+
newScript.onload = newScript.onerror = r;
|
|
164
|
+
});
|
|
165
|
+
wait = wait.then(() => p);
|
|
166
|
+
}
|
|
167
|
+
newScript.setAttribute(attr.name, attr.value);
|
|
168
|
+
}
|
|
169
|
+
newScript.dataset["zfbExec"] = "";
|
|
170
|
+
script.replaceWith(newScript);
|
|
171
|
+
}
|
|
172
|
+
return wait;
|
|
173
|
+
}
|
|
174
|
+
// Add a new entry to the browser history. This also sets the new page in the browser address bar.
|
|
175
|
+
// Sets the scroll position according to the hash fragment of the new location.
|
|
176
|
+
const moveToLocation = (to, from, options, pageTitleForBrowserHistory, historyState) => {
|
|
177
|
+
const intraPage = samePage(from, to);
|
|
178
|
+
const targetPageTitle = document.title;
|
|
179
|
+
document.title = pageTitleForBrowserHistory;
|
|
180
|
+
let scrolledToTop = false;
|
|
181
|
+
if (to.href !== location.href && !historyState) {
|
|
182
|
+
if (options.history === "replace") {
|
|
183
|
+
const current = history.state;
|
|
184
|
+
history.replaceState({
|
|
185
|
+
...options.state,
|
|
186
|
+
index: current.index,
|
|
187
|
+
scrollX: current.scrollX,
|
|
188
|
+
scrollY: current.scrollY,
|
|
189
|
+
}, "", to.href);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
history.pushState({ ...options.state, index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 }, "", to.href);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
document.title = targetPageTitle;
|
|
196
|
+
// now we are on the new page for non-history navigation!
|
|
197
|
+
// (with history navigation page change happens before popstate is fired)
|
|
198
|
+
originalLocation = to;
|
|
199
|
+
// freshly loaded pages start from the top
|
|
200
|
+
if (!intraPage) {
|
|
201
|
+
scrollTo({ left: 0, top: 0, behavior: "instant" });
|
|
202
|
+
scrolledToTop = true;
|
|
203
|
+
}
|
|
204
|
+
if (historyState) {
|
|
205
|
+
scrollTo(historyState.scrollX, historyState.scrollY);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
if (to.hash) {
|
|
209
|
+
// because we are already on the target page ...
|
|
210
|
+
// ... what comes next is an intra-page navigation
|
|
211
|
+
// that won't reload the page but instead scroll to the fragment
|
|
212
|
+
history.scrollRestoration = "auto";
|
|
213
|
+
const savedState = history.state;
|
|
214
|
+
location.href = to.href; // this kills the history state on Firefox
|
|
215
|
+
if (!history.state) {
|
|
216
|
+
history.replaceState(savedState, ""); // this restores the history state
|
|
217
|
+
if (intraPage) {
|
|
218
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
if (!scrolledToTop) {
|
|
224
|
+
scrollTo({ left: 0, top: 0, behavior: "instant" });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
history.scrollRestoration = "manual";
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
function preloadStyleLinks(newDocument) {
|
|
231
|
+
const links = [];
|
|
232
|
+
for (const el of newDocument.querySelectorAll("head link[rel=stylesheet]")) {
|
|
233
|
+
// 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")}"]`)) {
|
|
235
|
+
const c = document.createElement("link");
|
|
236
|
+
c.setAttribute("rel", "preload");
|
|
237
|
+
c.setAttribute("as", "style");
|
|
238
|
+
c.setAttribute("href", el.getAttribute("href"));
|
|
239
|
+
links.push(new Promise((resolve) => {
|
|
240
|
+
["load", "error"].forEach((evName) => c.addEventListener(evName, resolve));
|
|
241
|
+
document.head.append(c);
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return links;
|
|
246
|
+
}
|
|
247
|
+
// replace head and body of the windows document with contents from newDocument
|
|
248
|
+
// if !popstate, update the history entry and scroll position according to toLocation
|
|
249
|
+
// if popState is given, this holds the scroll position for history navigation
|
|
250
|
+
// if fallback === "animate" then simulate view transitions
|
|
251
|
+
async function updateDOM(preparationEvent, options, currentTransition, historyState, fallback) {
|
|
252
|
+
async function animate(phase) {
|
|
253
|
+
function isInfinite(animation) {
|
|
254
|
+
const effect = animation.effect;
|
|
255
|
+
if (!effect || !(effect instanceof KeyframeEffect) || !effect.target)
|
|
256
|
+
return false;
|
|
257
|
+
const style = window.getComputedStyle(effect.target, effect.pseudoElement);
|
|
258
|
+
return style.animationIterationCount === "infinite";
|
|
259
|
+
}
|
|
260
|
+
const currentAnimations = document.getAnimations();
|
|
261
|
+
// Trigger view transition animations waiting for data-zfb-transition-fallback
|
|
262
|
+
document.documentElement.setAttribute(OLD_NEW_ATTR, phase);
|
|
263
|
+
const nextAnimations = document.getAnimations();
|
|
264
|
+
const newAnimations = nextAnimations.filter((a) => !currentAnimations.includes(a) && !isInfinite(a));
|
|
265
|
+
// Wait for all new animations to finish (resolved or rejected).
|
|
266
|
+
// Do not reject on canceled ones.
|
|
267
|
+
return Promise.allSettled(newAnimations.map((a) => a.finished));
|
|
268
|
+
}
|
|
269
|
+
const animateFallbackOld = async () => {
|
|
270
|
+
if (fallback === "animate" &&
|
|
271
|
+
!currentTransition.transitionSkipped &&
|
|
272
|
+
!preparationEvent.signal.aborted) {
|
|
273
|
+
try {
|
|
274
|
+
await animate("old");
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// animate might reject as a consequence of a call to skipTransition()
|
|
278
|
+
// ignored on purpose
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
const pageTitleForBrowserHistory = document.title; // document.title will be overridden by swap()
|
|
283
|
+
// Cancel deferred-hydration callbacks for old-body islands before the swap
|
|
284
|
+
// so rIC / IntersectionObserver fires do not run against orphan elements.
|
|
285
|
+
// Called before doSwap() which dispatches `zfb:before-swap` then mutates the DOM.
|
|
286
|
+
cancelPendingIslands();
|
|
287
|
+
// Unmount mounted islands on the OLD body before the swap so Preact/React
|
|
288
|
+
// trees receive render(null, element) / root.unmount() and their useEffect
|
|
289
|
+
// cleanups fire. Must happen after cancelPendingIslands() and before doSwap()
|
|
290
|
+
// so document.body still points to the old body.
|
|
291
|
+
unmountIslands();
|
|
292
|
+
const swapEvent = await doSwap(preparationEvent, currentTransition.viewTransition, animateFallbackOld);
|
|
293
|
+
moveToLocation(swapEvent.to, swapEvent.from, options, pageTitleForBrowserHistory, historyState);
|
|
294
|
+
triggerEvent("zfb:after-swap");
|
|
295
|
+
// Resolve the finished promise of the simulation's ViewTransition.
|
|
296
|
+
// For 'animate', wait for the new-page animation to complete first.
|
|
297
|
+
// For other fallback modes (e.g. 'swap'), resolve immediately — no animation needed.
|
|
298
|
+
if (fallback === "animate" && !currentTransition.transitionSkipped && !swapEvent.signal.aborted) {
|
|
299
|
+
animate("new").finally(() => currentTransition.viewTransitionFinished());
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
currentTransition.viewTransitionFinished?.();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function abortAndRecreateMostRecentNavigation() {
|
|
306
|
+
mostRecentNavigation?.controller.abort();
|
|
307
|
+
return (mostRecentNavigation = {
|
|
308
|
+
controller: new AbortController(),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
async function transition(direction, from, to, options, historyState, hasUAVisualTransition = false) {
|
|
312
|
+
// The most recent navigation always has precedence
|
|
313
|
+
// Yes, there can be several navigation instances as the user can click links
|
|
314
|
+
// while we fetch content or simulate view transitions. Even synchronous creations are possible
|
|
315
|
+
// e.g. by calling navigate() from a transition event.
|
|
316
|
+
// Invariant: all but the most recent navigation are already aborted.
|
|
317
|
+
const currentNavigation = abortAndRecreateMostRecentNavigation();
|
|
318
|
+
// not ours
|
|
319
|
+
if (!transitionEnabledOnThisPage() || location.origin !== to.origin) {
|
|
320
|
+
if (currentNavigation === mostRecentNavigation)
|
|
321
|
+
mostRecentNavigation = undefined;
|
|
322
|
+
location.href = to.href;
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const navigationType = historyState
|
|
326
|
+
? "traverse"
|
|
327
|
+
: options.history === "replace"
|
|
328
|
+
? "replace"
|
|
329
|
+
: "push";
|
|
330
|
+
if (navigationType !== "traverse") {
|
|
331
|
+
updateScrollPosition({ scrollX, scrollY });
|
|
332
|
+
}
|
|
333
|
+
if (samePage(from, to) && !options.formData) {
|
|
334
|
+
if ((direction !== "back" && to.hash) || (direction === "back" && from.hash)) {
|
|
335
|
+
moveToLocation(to, from, options, document.title, historyState);
|
|
336
|
+
if (currentNavigation === mostRecentNavigation)
|
|
337
|
+
mostRecentNavigation = undefined;
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const prepEvent = await doPreparation(from, to, direction, navigationType, options.sourceElement, options.info, currentNavigation.controller.signal, options.formData, defaultLoader);
|
|
342
|
+
if (prepEvent.defaultPrevented || prepEvent.signal.aborted) {
|
|
343
|
+
if (currentNavigation === mostRecentNavigation)
|
|
344
|
+
mostRecentNavigation = undefined;
|
|
345
|
+
triggerEvent("zfb:navigation-aborted");
|
|
346
|
+
if (!prepEvent.signal.aborted) {
|
|
347
|
+
// not aborted -> delegate to browser
|
|
348
|
+
location.href = to.href;
|
|
349
|
+
}
|
|
350
|
+
// and / or exit
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
async function defaultLoader(preparationEvent) {
|
|
354
|
+
const href = preparationEvent.to.href;
|
|
355
|
+
const init = { signal: preparationEvent.signal };
|
|
356
|
+
if (preparationEvent.formData) {
|
|
357
|
+
init.method = "POST";
|
|
358
|
+
const form = preparationEvent.sourceElement instanceof HTMLFormElement
|
|
359
|
+
? preparationEvent.sourceElement
|
|
360
|
+
: preparationEvent.sourceElement instanceof HTMLElement &&
|
|
361
|
+
"form" in preparationEvent.sourceElement
|
|
362
|
+
? preparationEvent.sourceElement.form
|
|
363
|
+
: preparationEvent.sourceElement?.closest("form");
|
|
364
|
+
// Form elements without enctype explicitly set default to application/x-www-form-urlencoded.
|
|
365
|
+
// In order to maintain compatibility with Astro 4.x, we need to check the value of enctype
|
|
366
|
+
// on the attributes property rather than accessing .enctype directly. Astro 5.x may
|
|
367
|
+
// introduce defaulting to application/x-www-form-urlencoded as a breaking change, and then
|
|
368
|
+
// we can access .enctype directly.
|
|
369
|
+
//
|
|
370
|
+
// Note: getNamedItem can return null in real life, even if TypeScript doesn't think so, hence
|
|
371
|
+
// the ?.
|
|
372
|
+
init.body =
|
|
373
|
+
form !== undefined &&
|
|
374
|
+
Reflect.get(HTMLFormElement.prototype, "attributes", form).getNamedItem("enctype")
|
|
375
|
+
?.value === "application/x-www-form-urlencoded"
|
|
376
|
+
? new URLSearchParams(preparationEvent.formData)
|
|
377
|
+
: preparationEvent.formData;
|
|
378
|
+
}
|
|
379
|
+
const response = await fetchHTML(href, init);
|
|
380
|
+
// If there is a problem fetching the new page, just do an MPA navigation to it.
|
|
381
|
+
if (response === null) {
|
|
382
|
+
preparationEvent.preventDefault();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// if there was a redirection, show the final URL in the browser's address bar
|
|
386
|
+
if (response.redirected) {
|
|
387
|
+
const redirectedTo = new URL(response.redirected);
|
|
388
|
+
// but do not redirect cross origin
|
|
389
|
+
if (redirectedTo.origin !== preparationEvent.to.origin) {
|
|
390
|
+
preparationEvent.preventDefault();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
// preserve fragment
|
|
394
|
+
const fragment = preparationEvent.to.hash;
|
|
395
|
+
preparationEvent.to = redirectedTo;
|
|
396
|
+
preparationEvent.to.hash = fragment;
|
|
397
|
+
}
|
|
398
|
+
parser ??= new DOMParser();
|
|
399
|
+
preparationEvent.newDocument = parser.parseFromString(response.html, response.mediaType);
|
|
400
|
+
// The next line might look like a hack,
|
|
401
|
+
// but it is actually necessary as noscript elements
|
|
402
|
+
// and their contents are returned as markup by the parser,
|
|
403
|
+
// see https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString
|
|
404
|
+
preparationEvent.newDocument.querySelectorAll("noscript").forEach((el) => el.remove());
|
|
405
|
+
// If ClientRouter is not enabled on the incoming page, do a full page load to it.
|
|
406
|
+
// Unless this was a form submission, in which case we do not want to trigger another mutation.
|
|
407
|
+
if (!preparationEvent.newDocument.querySelector('[name="zfb-view-transitions-enabled"]') &&
|
|
408
|
+
!preparationEvent.formData) {
|
|
409
|
+
preparationEvent.preventDefault();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const links = preloadStyleLinks(preparationEvent.newDocument);
|
|
413
|
+
links.length && !preparationEvent.signal.aborted && (await Promise.all(links));
|
|
414
|
+
// W3C2: prepareForClientOnlyComponents() goes here. Astro's DEV-only iframe
|
|
415
|
+
// trick that hoists Vite per-component CSS for client:only islands does not
|
|
416
|
+
// apply to zfb (W1B §13.5 — zfb islands inject CSS via the bundle), so the
|
|
417
|
+
// call is intentionally absent rather than stubbed.
|
|
418
|
+
}
|
|
419
|
+
async function abortAndRecreateMostRecentTransition() {
|
|
420
|
+
if (mostRecentTransition) {
|
|
421
|
+
if (mostRecentTransition.viewTransition) {
|
|
422
|
+
try {
|
|
423
|
+
mostRecentTransition.viewTransition.skipTransition();
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
// might throw AbortError DOMException. Ignored on purpose.
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
// UpdateCallbackDone might already been settled, i.e. if the previous transition finished updating the DOM.
|
|
430
|
+
// Could not take long, we wait for it to avoid parallel updates
|
|
431
|
+
// (which are very unlikely as long as swap() is not async).
|
|
432
|
+
await mostRecentTransition.viewTransition.updateCallbackDone;
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
// There was an error in the update callback of the transition which we cancel.
|
|
436
|
+
// Ignored on purpose
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return (mostRecentTransition = { transitionSkipped: false });
|
|
441
|
+
}
|
|
442
|
+
const currentTransition = await abortAndRecreateMostRecentTransition();
|
|
443
|
+
if (prepEvent.signal.aborted) {
|
|
444
|
+
if (currentNavigation === mostRecentNavigation)
|
|
445
|
+
mostRecentNavigation = undefined;
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
document.documentElement.setAttribute(DIRECTION_ATTR, prepEvent.direction);
|
|
449
|
+
if (supportsViewTransitions && !hasUAVisualTransition) {
|
|
450
|
+
// This automatically cancels any previous transition
|
|
451
|
+
// We also already took care that the earlier update callback got through
|
|
452
|
+
currentTransition.viewTransition = document.startViewTransition(async () => await updateDOM(prepEvent, options, currentTransition, historyState));
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
// Simulation mode requires a bit more manual work.
|
|
456
|
+
// Also used when PopStateEvent.hasUAVisualTransition indicates the browser already
|
|
457
|
+
// provided a visual transition (e.g. Safari swipe gesture) — in that case, fallback
|
|
458
|
+
// is "swap" to skip animations.
|
|
459
|
+
const updateDone = (async () => {
|
|
460
|
+
// Immediately paused to set up the ViewTransition object for Fallback mode
|
|
461
|
+
await Promise.resolve(); // hop through the micro task queue
|
|
462
|
+
await updateDOM(prepEvent, options, currentTransition, historyState, hasUAVisualTransition ? "swap" : getFallback());
|
|
463
|
+
return undefined;
|
|
464
|
+
})();
|
|
465
|
+
// When the updateDone promise is settled,
|
|
466
|
+
// we have run and awaited all swap functions and the after-swap event
|
|
467
|
+
// This qualifies for "updateCallbackDone".
|
|
468
|
+
//
|
|
469
|
+
// For the build in ViewTransition, "ready" settles shortly after "updateCallbackDone",
|
|
470
|
+
// i.e. after all pseudo elements are created and the animation is about to start.
|
|
471
|
+
// In simulation mode the "old" animation starts before swap,
|
|
472
|
+
// the "new" animation starts after swap. That is not really comparable.
|
|
473
|
+
// Thus we go with "very, very shortly after updateCallbackDone" and make both equal.
|
|
474
|
+
//
|
|
475
|
+
// "finished" resolves after all animations are done.
|
|
476
|
+
currentTransition.viewTransition = {
|
|
477
|
+
updateCallbackDone: updateDone, // this is about correct
|
|
478
|
+
ready: updateDone, // good enough
|
|
479
|
+
// Finished promise could have been done better: finished rejects iff updateDone does.
|
|
480
|
+
// Our simulation always resolves, never rejects.
|
|
481
|
+
finished: new Promise((r) => (currentTransition.viewTransitionFinished = r)), // see end of updateDOM
|
|
482
|
+
skipTransition: () => {
|
|
483
|
+
currentTransition.transitionSkipped = true;
|
|
484
|
+
// This cancels all animations of the simulation
|
|
485
|
+
document.documentElement.removeAttribute(OLD_NEW_ATTR);
|
|
486
|
+
},
|
|
487
|
+
types: new Set(), // empty by default
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
// In earlier versions was then'ed on viewTransition.ready which would not execute
|
|
491
|
+
// if the visual part of the transition has errors or was skipped
|
|
492
|
+
currentTransition.viewTransition?.updateCallbackDone.finally(async () => {
|
|
493
|
+
await runScripts();
|
|
494
|
+
// Mount new island markers introduced by the body swap. Fire-and-forget;
|
|
495
|
+
// each island's scheduleHydrate call is async (idle / visible). Called after
|
|
496
|
+
// runScripts() so any new mountIslands() registration from inline scripts in
|
|
497
|
+
// the new page has already run. Called before onPageLoad() per W1B §12.2.
|
|
498
|
+
mountNewIslands();
|
|
499
|
+
onPageLoad();
|
|
500
|
+
announce();
|
|
501
|
+
});
|
|
502
|
+
// finished.ready and finished.finally are the same for the simulation but not
|
|
503
|
+
// necessarily for native view transition, where finished rejects when updateCallbackDone does.
|
|
504
|
+
currentTransition.viewTransition?.finished.finally(() => {
|
|
505
|
+
// exactOptionalPropertyTypes: true forbids assigning `undefined` to an optional
|
|
506
|
+
// `viewTransition?: ViewTransition` slot — `delete` is the equivalent reset.
|
|
507
|
+
delete currentTransition.viewTransition;
|
|
508
|
+
if (currentTransition === mostRecentTransition)
|
|
509
|
+
mostRecentTransition = undefined;
|
|
510
|
+
if (currentNavigation === mostRecentNavigation)
|
|
511
|
+
mostRecentNavigation = undefined;
|
|
512
|
+
document.documentElement.removeAttribute(DIRECTION_ATTR);
|
|
513
|
+
document.documentElement.removeAttribute(OLD_NEW_ATTR);
|
|
514
|
+
});
|
|
515
|
+
try {
|
|
516
|
+
// Compatibility:
|
|
517
|
+
// In an earlier version we awaited viewTransition.ready, which includes animation setup.
|
|
518
|
+
// Scripts that depend on the view transition pseudo elements should hook on viewTransition.ready.
|
|
519
|
+
await currentTransition.viewTransition?.updateCallbackDone;
|
|
520
|
+
}
|
|
521
|
+
catch (e) {
|
|
522
|
+
// This log doesn't make it worse than before, where we got error messages about uncaught exceptions, which can't be caught when the trigger was a click or history traversal.
|
|
523
|
+
// Needs more investigation on root causes if errors still occur sporadically
|
|
524
|
+
const err = e;
|
|
525
|
+
// biome-ignore lint/suspicious/noConsole: allowed
|
|
526
|
+
console.log("[zfb]", err.name, err.message, err.stack);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
let navigateOnServerWarned = false;
|
|
530
|
+
export async function navigate(href, options) {
|
|
531
|
+
if (inBrowser === false) {
|
|
532
|
+
if (!navigateOnServerWarned) {
|
|
533
|
+
// instantiate an error for the stacktrace to show to user.
|
|
534
|
+
const warning = new Error("The view transitions client API was called during a server side render. This may be unintentional as the navigate() function is expected to be called in response to user interactions. Please make sure that your usage is correct.");
|
|
535
|
+
warning.name = "Warning";
|
|
536
|
+
// biome-ignore lint/suspicious/noConsole: allowed
|
|
537
|
+
console.warn(warning);
|
|
538
|
+
navigateOnServerWarned = true;
|
|
539
|
+
}
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
await transition("forward", originalLocation, new URL(href, location.href), options ?? {});
|
|
543
|
+
}
|
|
544
|
+
function onPopState(ev) {
|
|
545
|
+
if (!transitionEnabledOnThisPage() && ev.state) {
|
|
546
|
+
// The current page doesn't have View Transitions enabled
|
|
547
|
+
// but the page we navigate to does (because it set the state).
|
|
548
|
+
// Do a full page refresh to reload the client-side router from the new page.
|
|
549
|
+
location.reload();
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
// History entries without state are created by the browser (e.g. for hash links)
|
|
553
|
+
// Our view transition entries always have state.
|
|
554
|
+
// Just ignore stateless entries.
|
|
555
|
+
// The browser will handle navigation fine without our help
|
|
556
|
+
if (ev.state === null) {
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const state = history.state;
|
|
560
|
+
const nextIndex = state.index;
|
|
561
|
+
const direction = nextIndex > currentHistoryIndex ? "forward" : "back";
|
|
562
|
+
currentHistoryIndex = nextIndex;
|
|
563
|
+
transition(direction, originalLocation, new URL(location.href), {}, state, ev.hasUAVisualTransition);
|
|
564
|
+
}
|
|
565
|
+
const onScrollEnd = () => {
|
|
566
|
+
// NOTE: our "popstate" event handler may call `pushState()` or
|
|
567
|
+
// `replaceState()` and then `scrollTo()`, which will fire "scroll" and
|
|
568
|
+
// "scrollend" events. To avoid redundant work and expensive calls to
|
|
569
|
+
// `replaceState()`, we simply check that the values are different before
|
|
570
|
+
// updating.
|
|
571
|
+
if (history.state && (scrollX !== history.state.scrollX || scrollY !== history.state.scrollY)) {
|
|
572
|
+
updateScrollPosition({ scrollX, scrollY });
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
// initialization
|
|
576
|
+
if (inBrowser) {
|
|
577
|
+
if (supportsViewTransitions || getFallback() !== "none") {
|
|
578
|
+
originalLocation = new URL(location.href);
|
|
579
|
+
addEventListener("popstate", onPopState);
|
|
580
|
+
addEventListener("load", onPageLoad);
|
|
581
|
+
// There's not a good way to record scroll position before a history back
|
|
582
|
+
// navigation, so we will record it when the user has stopped scrolling.
|
|
583
|
+
if ("onscrollend" in window)
|
|
584
|
+
addEventListener("scrollend", onScrollEnd);
|
|
585
|
+
else {
|
|
586
|
+
// Keep track of state between intervals
|
|
587
|
+
let intervalId, lastY, lastX, lastIndex;
|
|
588
|
+
const scrollInterval = () => {
|
|
589
|
+
// Check the index to see if a popstate event was fired
|
|
590
|
+
if (lastIndex !== history.state?.index) {
|
|
591
|
+
clearInterval(intervalId);
|
|
592
|
+
intervalId = undefined;
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
// Check if the user stopped scrolling
|
|
596
|
+
if (lastY === scrollY && lastX === scrollX) {
|
|
597
|
+
// Cancel the interval and update scroll positions
|
|
598
|
+
clearInterval(intervalId);
|
|
599
|
+
intervalId = undefined;
|
|
600
|
+
onScrollEnd();
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
((lastY = scrollY), (lastX = scrollX));
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
// We can't know when or how often scroll events fire, so we'll just use them to start intervals
|
|
608
|
+
addEventListener("scroll", () => {
|
|
609
|
+
if (intervalId !== undefined)
|
|
610
|
+
return;
|
|
611
|
+
((lastIndex = history.state?.index), (lastY = scrollY), (lastX = scrollX));
|
|
612
|
+
intervalId = window.setInterval(scrollInterval, 50);
|
|
613
|
+
}, { passive: true });
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
for (const script of document.getElementsByTagName("script")) {
|
|
617
|
+
detectScriptExecuted(script);
|
|
618
|
+
script.dataset["zfbExec"] = "";
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
// ---- W3C3: click + form intercept, public idempotent init() ----
|
|
622
|
+
// Returns true when the modifier-key combo or mouse button means "open in new tab / download".
|
|
623
|
+
// Matches Astro's `leavesWindow` helper in ClientRouter.astro.
|
|
624
|
+
const leavesWindow = (ev) => (ev.button !== undefined && ev.button !== 0) || // non-left-click
|
|
625
|
+
ev.metaKey || // new tab (Mac)
|
|
626
|
+
ev.ctrlKey || // new tab (Windows/Linux)
|
|
627
|
+
ev.altKey || // download
|
|
628
|
+
ev.shiftKey; // new window
|
|
629
|
+
// Track the last clicked element that will leave the window so form submit can check it.
|
|
630
|
+
let lastClickedElementLeavingWindow = null;
|
|
631
|
+
function handleClick(ev) {
|
|
632
|
+
let link = ev.target;
|
|
633
|
+
// Record whether this click will leave the window (used by form submit handler).
|
|
634
|
+
lastClickedElementLeavingWindow = leavesWindow(ev) ? link : null;
|
|
635
|
+
// Shadow DOM: prefer composedPath target over ev.target.
|
|
636
|
+
if (ev.composed) {
|
|
637
|
+
link = ev.composedPath()[0] ?? link;
|
|
638
|
+
}
|
|
639
|
+
// Walk up to the nearest <a>, <area>, or <svg:a>.
|
|
640
|
+
if (link instanceof Element) {
|
|
641
|
+
link = link.closest("a, area");
|
|
642
|
+
}
|
|
643
|
+
if (!(link instanceof HTMLAnchorElement) &&
|
|
644
|
+
!(link instanceof SVGAElement) &&
|
|
645
|
+
!(link instanceof HTMLAreaElement)) {
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
const linkEl = link;
|
|
649
|
+
const linkTarget = linkEl instanceof HTMLElement ? linkEl.target : linkEl.target.baseVal;
|
|
650
|
+
const href = linkEl instanceof HTMLElement ? linkEl.href : linkEl.href.baseVal;
|
|
651
|
+
if (!href)
|
|
652
|
+
return;
|
|
653
|
+
const origin = new URL(href, location.href).origin;
|
|
654
|
+
if (
|
|
655
|
+
// data-zfb-reload: caller wants a full browser reload, not a SPA transition.
|
|
656
|
+
linkEl.dataset["zfbReload"] !== undefined ||
|
|
657
|
+
// download attribute: let browser handle download.
|
|
658
|
+
linkEl.hasAttribute("download") ||
|
|
659
|
+
// Non-self target opens in a new context — skip.
|
|
660
|
+
(linkTarget && linkTarget !== "_self") ||
|
|
661
|
+
// Cross-origin: not ours to handle.
|
|
662
|
+
origin !== location.origin ||
|
|
663
|
+
// Modifier key / non-left-click combo: user wants new tab / window / download.
|
|
664
|
+
lastClickedElementLeavingWindow !== null ||
|
|
665
|
+
// Another handler already handled this event.
|
|
666
|
+
ev.defaultPrevented) {
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
ev.preventDefault();
|
|
670
|
+
navigate(href, {
|
|
671
|
+
// data-zfb-history="replace" opts a link into replaceState instead of pushState.
|
|
672
|
+
history: linkEl.dataset["zfbHistory"] === "replace" ? "replace" : "auto",
|
|
673
|
+
sourceElement: linkEl,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
function handleSubmit(ev) {
|
|
677
|
+
const el = ev.target;
|
|
678
|
+
const submitter = ev.submitter;
|
|
679
|
+
// If the submit was triggered by a modifier-key click, treat as normal browser submit.
|
|
680
|
+
const clickedWithKeys = submitter !== null && submitter === lastClickedElementLeavingWindow;
|
|
681
|
+
lastClickedElementLeavingWindow = null;
|
|
682
|
+
if (el.tagName !== "FORM" ||
|
|
683
|
+
ev.defaultPrevented ||
|
|
684
|
+
el.dataset["zfbReload"] !== undefined ||
|
|
685
|
+
clickedWithKeys) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const form = el;
|
|
689
|
+
const formData = new FormData(form, submitter ?? undefined);
|
|
690
|
+
// form.action / form.method can be shadowed by <input name="action"> / <input name="method">,
|
|
691
|
+
// so fall back to getAttribute() when the property is not a string. (Astro's comment.)
|
|
692
|
+
const formAction = typeof form.action === "string" ? form.action : form.getAttribute("action");
|
|
693
|
+
const formMethod = typeof form.method === "string" ? form.method : form.getAttribute("method");
|
|
694
|
+
// Resolve action: submitter formaction attr overrides form action, fallback to current path.
|
|
695
|
+
let action = submitter?.getAttribute("formaction") ?? formAction ?? location.pathname;
|
|
696
|
+
// Resolve method: submitter formmethod attr overrides form method, fallback to "get".
|
|
697
|
+
const method = submitter?.getAttribute("formmethod") ?? formMethod ?? "get";
|
|
698
|
+
// The "dialog" method is a special keyword used within <dialog> elements —
|
|
699
|
+
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#attr-fs-method
|
|
700
|
+
if (method === "dialog" || location.origin !== new URL(action, location.href).origin) {
|
|
701
|
+
// No SPA transition in these cases — let browser handle it.
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const options = { sourceElement: submitter ?? form };
|
|
705
|
+
if (method === "get") {
|
|
706
|
+
const params = new URLSearchParams(formData);
|
|
707
|
+
const url = new URL(action, location.href);
|
|
708
|
+
url.search = params.toString();
|
|
709
|
+
action = url.toString();
|
|
710
|
+
}
|
|
711
|
+
else {
|
|
712
|
+
options.formData = formData;
|
|
713
|
+
}
|
|
714
|
+
ev.preventDefault();
|
|
715
|
+
navigate(action, options);
|
|
716
|
+
}
|
|
717
|
+
// Guard flag — ensures click + submit listeners are registered only once even if
|
|
718
|
+
// init() is called multiple times (e.g. two <ClientRouter> mounts on the same page).
|
|
719
|
+
let initialized = false;
|
|
720
|
+
/**
|
|
721
|
+
* Wire up the client-router's click and form-submit intercepts.
|
|
722
|
+
* Safe to call multiple times — subsequent calls are no-ops (idempotent).
|
|
723
|
+
*
|
|
724
|
+
* @param _options - Forward-compat hook matching Astro's init() signature. Ignored in v1.
|
|
725
|
+
*/
|
|
726
|
+
export function init(_options) {
|
|
727
|
+
if (initialized)
|
|
728
|
+
return;
|
|
729
|
+
initialized = true;
|
|
730
|
+
if (!inBrowser)
|
|
731
|
+
return;
|
|
732
|
+
if (!supportsViewTransitions && getFallback() === "none")
|
|
733
|
+
return;
|
|
734
|
+
document.addEventListener("click", handleClick);
|
|
735
|
+
document.addEventListener("submit", handleSubmit);
|
|
736
|
+
// Prefetch hook intentionally omitted from v1 — see https://github.com/zudolab/zudo-doc/issues/1527
|
|
737
|
+
// (Followup tracker for porting Astro prefetch module).
|
|
738
|
+
}
|
|
739
|
+
//# sourceMappingURL=router.js.map
|