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