@takazudo/zfb-runtime 2.6.0 → 2.7.1
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 +47 -16
- package/dist/client-router/index.js +5 -1
- package/dist/client-router/index.js.map +1 -1
- package/dist/client-router/router.d.ts +8 -2
- package/dist/client-router/router.js +179 -92
- package/dist/client-router/router.js.map +1 -1
- package/dist/client-router-component.d.ts +74 -0
- package/dist/client-router-component.js +157 -0
- package/dist/client-router-component.js.map +1 -0
- package/dist/client-router.d.ts +1 -72
- package/dist/client-router.js +19 -146
- package/dist/client-router.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -79,7 +79,7 @@ import type {
|
|
|
79
79
|
|
|
80
80
|
```ts
|
|
81
81
|
import {
|
|
82
|
-
ClientRouter, // framework-agnostic <head> helper —
|
|
82
|
+
ClientRouter, // framework-agnostic <head> helper — mount it to opt a page into SPA navigation
|
|
83
83
|
navigate, // imperative navigation
|
|
84
84
|
supportsViewTransitions, // browser capability check
|
|
85
85
|
transitionEnabledOnThisPage, // reads zfb-view-transitions-enabled meta
|
|
@@ -104,6 +104,13 @@ import type { ContentSnapshot } from "@takazudo/zfb-runtime/snapshot";
|
|
|
104
104
|
import { ClientRouter } from "@takazudo/zfb-runtime/client-router";
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
The two differ in one way that matters: the root barrel is **side-effect-free**
|
|
108
|
+
— importing anything from it, `ClientRouter` included, registers no listeners
|
|
109
|
+
and touches no history — while evaluating the `./client-router` subpath calls
|
|
110
|
+
`init()` and activates the router on the current page. See [Enabling SPA
|
|
111
|
+
soft-navigation](#enabling-spa-soft-navigation-the-runtime-ships-automatically)
|
|
112
|
+
below for how a normal zfb build gets that activation for you.
|
|
113
|
+
|
|
107
114
|
#### Enabling SPA soft-navigation (the runtime ships automatically)
|
|
108
115
|
|
|
109
116
|
Mounting `<ClientRouter />` in your layout `<head>` is normally **all you need**.
|
|
@@ -146,9 +153,15 @@ export default function Layout({ children }) {
|
|
|
146
153
|
SPA navigation set so traversal behavior is deterministic.
|
|
147
154
|
|
|
148
155
|
**How the runtime reaches the browser.** `<ClientRouter />` itself only renders
|
|
149
|
-
SSR `<head>` tags
|
|
150
|
-
|
|
151
|
-
|
|
156
|
+
SSR `<head>` tags, and the module it lives in is pure: importing `ClientRouter`
|
|
157
|
+
from the `@takazudo/zfb-runtime` barrel registers nothing and runs no code on
|
|
158
|
+
the page. Everything the router does at startup — seeding this page's history
|
|
159
|
+
entry and scroll position, marking the scripts the initial load already ran, and
|
|
160
|
+
registering the `popstate` / `load` / `pageshow` / scroll listeners plus the
|
|
161
|
+
click and form-submit intercepts — happens inside a single `init()` call.
|
|
162
|
+
|
|
163
|
+
`init()` is invoked as a side effect when `@takazudo/zfb-runtime/client-router`
|
|
164
|
+
is evaluated in the browser, guarded so it never runs during SSR:
|
|
152
165
|
|
|
153
166
|
```ts
|
|
154
167
|
// inside @takazudo/zfb-runtime — client-router.ts
|
|
@@ -157,6 +170,9 @@ if (typeof document !== "undefined") {
|
|
|
157
170
|
}
|
|
158
171
|
```
|
|
159
172
|
|
|
173
|
+
`init()` is idempotent: calling it again (a second `<ClientRouter />` mount, an
|
|
174
|
+
HMR re-run, a manual call) is a no-op for the parts already done.
|
|
175
|
+
|
|
160
176
|
To get that side-effect import into the client bundle, zfb's island scanner
|
|
161
177
|
detects when a page transitively reaches `<ClientRouter />` and injects
|
|
162
178
|
`import "@takazudo/zfb-runtime/client-router"` into the islands asset
|
|
@@ -188,7 +204,10 @@ trigger — firing on them would ship the runtime to projects that only referenc
|
|
|
188
204
|
`rt.ClientRouter`, and
|
|
189
205
|
- a type-only import — `import type { ClientRouter }` (or `{ type ClientRouter }`).
|
|
190
206
|
|
|
191
|
-
|
|
207
|
+
When detection misses, nothing else picks up the slack: the barrel import is
|
|
208
|
+
side-effect-free, so an undetected `<ClientRouter />` renders its meta tags into
|
|
209
|
+
`<head>` and no runtime is ever shipped to activate against them. If
|
|
210
|
+
soft-navigation is not working because your reference takes one of these forms
|
|
192
211
|
(or the mounting module is otherwise not reachable from a page), force the
|
|
193
212
|
runtime in with an explicit side-effect import from a page-reachable
|
|
194
213
|
`"use client"` island. The island renders nothing; running its bundle in the
|
|
@@ -228,17 +247,29 @@ for the full API.
|
|
|
228
247
|
|
|
229
248
|
#### `navigate()` needs `<ClientRouter />` mounted on the current page
|
|
230
249
|
|
|
231
|
-
The root barrel exports `navigate` and `syncHistoryEntry`, but **not** `init
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
250
|
+
The root barrel exports `navigate` and `syncHistoryEntry`, but **not** `init`,
|
|
251
|
+
and importing from that barrel installs no interception listeners. Each helper
|
|
252
|
+
still does exactly what its own docs say when you call it — a direct
|
|
253
|
+
`navigate()` call navigates, `syncHistoryEntry()` writes its history entry —
|
|
254
|
+
but nothing starts intercepting the user's link clicks and form submits on
|
|
255
|
+
your behalf.
|
|
256
|
+
|
|
257
|
+
Soft navigation needs two separate things on the current page:
|
|
258
|
+
|
|
259
|
+
1. **The opt-in meta tag.** `navigate()` reaches `<meta
|
|
260
|
+
name="zfb-view-transitions-enabled">`, which `<ClientRouter />` renders into
|
|
261
|
+
`<head>`, before it will do a soft swap; with that tag absent it falls back
|
|
262
|
+
to a full `location.href` load.
|
|
263
|
+
2. **An activated router.** That comes from `init()` — in a normal zfb build,
|
|
264
|
+
from the `import "@takazudo/zfb-runtime/client-router"` the island scanner
|
|
265
|
+
injects once it sees a page reach `<ClientRouter />` (see above).
|
|
266
|
+
|
|
267
|
+
So the answer for both is the same: mount `<ClientRouter />` in the layout
|
|
268
|
+
`<head>` of every page that should be soft-navigable, and let the build ship
|
|
269
|
+
the runtime. `init` itself is exported from the
|
|
270
|
+
`@takazudo/zfb-runtime/client-router` subpath, not the root barrel, for the
|
|
271
|
+
rare case where you want to call it directly instead of relying on the
|
|
272
|
+
subpath's own import-time activation.
|
|
242
273
|
|
|
243
274
|
#### Persisting elements and island state across navigations (`data-zfb-transition-persist`)
|
|
244
275
|
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// Public API surface for the client-router module.
|
|
2
2
|
// This file is the barrel for @takazudo/zfb-runtime's client-router export.
|
|
3
3
|
// W3D adds the <ClientRouter /> component and ClientRouterProps re-exports.
|
|
4
|
-
// Component (W3D).
|
|
4
|
+
// Component (W3D). Re-exported via the activation shim `../client-router.js`
|
|
5
|
+
// (not `../client-router-component.js` directly) so importing this subpath
|
|
6
|
+
// barrel — the target of the islands bundler's auto-injected
|
|
7
|
+
// `import "@takazudo/zfb-runtime/client-router"` — keeps activating the
|
|
8
|
+
// router at module eval, byte-compatible with pre-split behavior (#2437).
|
|
5
9
|
export { ClientRouter } from "../client-router.js";
|
|
6
10
|
export { TRANSITION_BEFORE_PREPARATION, TRANSITION_AFTER_PREPARATION, TRANSITION_BEFORE_SWAP, TRANSITION_AFTER_SWAP, TRANSITION_PAGE_LOAD, TRANSITION_NAVIGATION_ABORTED, TransitionBeforePreparationEvent, TransitionBeforeSwapEvent, isTransitionBeforePreparationEvent, isTransitionBeforeSwapEvent, } from "./events.js";
|
|
7
11
|
export { swapFunctions, swap } from "./swap-functions.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/client-router/index.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,4EAA4E;AAC5E,4EAA4E;AAE5E,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/client-router/index.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,4EAA4E;AAC5E,4EAA4E;AAE5E,6EAA6E;AAC7E,2EAA2E;AAC3E,6DAA6D;AAC7D,wEAAwE;AACxE,0EAA0E;AAC1E,OAAO,EAAE,YAAY,EAA0B,MAAM,qBAAqB,CAAC;AAE3E,OAAO,EACL,6BAA6B,EAC7B,4BAA4B,EAC5B,sBAAsB,EACtB,qBAAqB,EACrB,oBAAoB,EACpB,6BAA6B,EAC7B,gCAAgC,EAChC,yBAAyB,EACzB,kCAAkC,EAClC,2BAA2B,GAC5B,MAAM,aAAa,CAAC;AAUrB,OAAO,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAE1D,yBAAyB;AACzB,sEAAsE;AACtE,oDAAoD;AACpD,yFAAyF;AACzF,iFAAiF;AACjF,OAAO,EACL,QAAQ,EACR,uBAAuB,EACvB,2BAA2B,EAC3B,IAAI,EACJ,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAGrB,kCAAkC;AAClC,8EAA8E;AAC9E,kCAAkC;AAClC,OAAO,EAAE,QAAQ,EAAE,IAAI,IAAI,YAAY,EAAE,MAAM,eAAe,CAAC","sourcesContent":["// Public API surface for the client-router module.\n// This file is the barrel for @takazudo/zfb-runtime's client-router export.\n// W3D adds the <ClientRouter /> component and ClientRouterProps re-exports.\n\n// Component (W3D). Re-exported via the activation shim `../client-router.js`\n// (not `../client-router-component.js` directly) so importing this subpath\n// barrel — the target of the islands bundler's auto-injected\n// `import \"@takazudo/zfb-runtime/client-router\"` — keeps activating the\n// router at module eval, byte-compatible with pre-split behavior (#2437).\nexport { ClientRouter, type ClientRouterProps } from \"../client-router.js\";\n\nexport {\n TRANSITION_BEFORE_PREPARATION,\n TRANSITION_AFTER_PREPARATION,\n TRANSITION_BEFORE_SWAP,\n TRANSITION_AFTER_SWAP,\n TRANSITION_PAGE_LOAD,\n TRANSITION_NAVIGATION_ABORTED,\n TransitionBeforePreparationEvent,\n TransitionBeforeSwapEvent,\n isTransitionBeforePreparationEvent,\n isTransitionBeforeSwapEvent,\n} from \"./events.js\";\n\nexport type {\n Direction,\n Fallback,\n NavigationTypeString,\n Options,\n SyncHistoryEntryOptions,\n} from \"./types.js\";\n\nexport { swapFunctions, swap } from \"./swap-functions.js\";\n\n// Router public surface.\n// - W3C1: `supportsViewTransitions`, `transitionEnabledOnThisPage`.\n// - W3C2: `navigate()` (public navigation entry).\n// - W3C3: `init()` (idempotent bootstrap: registers click + form intercept listeners).\n// - W2 (#1377): `syncHistoryEntry()` (history bookkeeping without navigation).\nexport {\n navigate,\n supportsViewTransitions,\n transitionEnabledOnThisPage,\n init,\n syncHistoryEntry,\n} from \"./router.js\";\nexport type { InitOptions } from \"./router.js\";\n\n// Prefetch public surface (#276).\n// `init` from prefetch.ts is re-exported as `prefetchInit` to avoid colliding\n// with the router's `init` above.\nexport { prefetch, init as prefetchInit } from \"./prefetch.js\";\nexport type { PrefetchStrategy, PrefetchInitOptions, PrefetchOptions } from \"./prefetch.js\";\n"]}
|
|
@@ -35,8 +35,14 @@ export interface InitOptions {
|
|
|
35
35
|
prefetchAll?: boolean;
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
38
|
+
* Bootstrap the client router on this page: restore its history entry, mark
|
|
39
|
+
* the already-executed scripts, and wire up the navigation, click and
|
|
40
|
+
* form-submit listeners. Safe to call multiple times — each of the two phases
|
|
41
|
+
* runs at most once (idempotent).
|
|
42
|
+
*
|
|
43
|
+
* Evaluating this module does nothing; every side effect the router performs
|
|
44
|
+
* at startup happens here (#2436). Two once-guards rather than one, because
|
|
45
|
+
* the phases have different eligibility — see `pageStateSeeded`.
|
|
40
46
|
*
|
|
41
47
|
* @param _options - Forward-compat hook matching Astro's init() signature. Ignored in v1.
|
|
42
48
|
*/
|
|
@@ -30,9 +30,11 @@
|
|
|
30
30
|
// W3C2 additions (this file):
|
|
31
31
|
// - `navigate()` public entry.
|
|
32
32
|
// - `onPopState`, `onScrollEnd`.
|
|
33
|
-
// -
|
|
34
|
-
//
|
|
35
|
-
//
|
|
33
|
+
// - Seeding `currentHistoryIndex` from `history.state`, registering popstate /
|
|
34
|
+
// load / scrollend listeners, and marking already-executed scripts with
|
|
35
|
+
// `dataset["zfbExec"] = ""`. W3C2 did all of this in two bare top-level
|
|
36
|
+
// `if (inBrowser)` blocks; #2436 folded both into `init()` (see the two
|
|
37
|
+
// once-guards there) so evaluating this module has no side effect at all.
|
|
36
38
|
//
|
|
37
39
|
// W3C1 items deferred to — and since implemented in — W3C3:
|
|
38
40
|
// - `announce()` route-announcer implementation (see the route-announcer block below;
|
|
@@ -146,19 +148,31 @@ let parser;
|
|
|
146
148
|
// you can figure it using an index. On pushState the index is incremented so you
|
|
147
149
|
// can use that to determine popstate if going forward or back.
|
|
148
150
|
let currentHistoryIndex = 0;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
151
|
+
// Once-guard for the navigation bookkeeping below. `init()` sets it too, so a
|
|
152
|
+
// later ensureNavigationState() can never clobber the activation-time seed.
|
|
153
|
+
let navigationStateSeeded = false;
|
|
154
|
+
/**
|
|
155
|
+
* Seed the module's navigation bookkeeping — `originalLocation` (declared
|
|
156
|
+
* without an initializer) and the tracked history index — from the live
|
|
157
|
+
* document, once.
|
|
158
|
+
*
|
|
159
|
+
* Deliberately side-effect free beyond those two variables: it registers no
|
|
160
|
+
* listener, writes no history entry, never scrolls, and never marks scripts.
|
|
161
|
+
* All of that belongs to {@link init}. Its only job is to keep an init-less
|
|
162
|
+
* `navigate()` / `syncHistoryEntry()` from reading `undefined` or stamping a
|
|
163
|
+
* fresh entry with the module default index — using the router without
|
|
164
|
+
* activating it is documented as unsupported, but it must not throw and must
|
|
165
|
+
* not mis-stamp. An existing finite `history.state.index` is ADOPTED, never
|
|
166
|
+
* reset: on a page whose entry is already index 7, the next push must be 8. (#2436)
|
|
167
|
+
*/
|
|
168
|
+
function ensureNavigationState() {
|
|
169
|
+
if (navigationStateSeeded || !inBrowser)
|
|
170
|
+
return;
|
|
171
|
+
navigationStateSeeded = true;
|
|
172
|
+
originalLocation = new URL(location.href);
|
|
173
|
+
const index = history.state?.index;
|
|
174
|
+
if (Number.isFinite(index))
|
|
175
|
+
currentHistoryIndex = index;
|
|
162
176
|
}
|
|
163
177
|
// returns the contents of the page or null if the router can't deal with it.
|
|
164
178
|
async function fetchHTML(href, init) {
|
|
@@ -369,6 +383,9 @@ export function syncHistoryEntry(url, options = {}) {
|
|
|
369
383
|
}
|
|
370
384
|
return;
|
|
371
385
|
}
|
|
386
|
+
// Adopt this page's existing history index before stamping an entry — an
|
|
387
|
+
// init-less caller must not reset a live entry's index to the module default.
|
|
388
|
+
ensureNavigationState();
|
|
372
389
|
const to = new URL(url, location.href);
|
|
373
390
|
// Cross-origin is not ours to manage. The History API would throw a native
|
|
374
391
|
// SecurityError for a cross-origin URL, but we guard explicitly so the failure
|
|
@@ -810,6 +827,9 @@ export async function navigate(href, options) {
|
|
|
810
827
|
}
|
|
811
828
|
return;
|
|
812
829
|
}
|
|
830
|
+
// `originalLocation` is seeded by init(); seed it here too so an init-less
|
|
831
|
+
// caller never hands transition() an undefined "from" URL.
|
|
832
|
+
ensureNavigationState();
|
|
813
833
|
await transition("forward", originalLocation, new URL(href, location.href), options ?? {});
|
|
814
834
|
}
|
|
815
835
|
function onPopState(ev) {
|
|
@@ -864,22 +884,22 @@ const onScrollEnd = () => {
|
|
|
864
884
|
};
|
|
865
885
|
// zfb-only addition (no Astro upstream — see file header "zfb-only additions").
|
|
866
886
|
// WebKit serves Back navigations after an SPA route change from the bfcache:
|
|
867
|
-
// the page is restored without re-evaluating this module
|
|
868
|
-
//
|
|
869
|
-
// never re-runs and the tracked index can desync from
|
|
870
|
-
// A desynced index makes onPopState's direction calc
|
|
871
|
-
// entry. On a persisted (bfcache) restore we re-seed
|
|
872
|
-
// `originalLocation` "from" URL from the live state,
|
|
873
|
-
// mirroring
|
|
874
|
-
// falsy/absent) and idempotent (re-seeding from the same state
|
|
875
|
-
// nothing and fires no transition).
|
|
887
|
+
// the page is restored without re-evaluating this module or re-running init(),
|
|
888
|
+
// so `seedPageState()` below (which sets `currentHistoryIndex` from
|
|
889
|
+
// `history.state.index`) never re-runs and the tracked index can desync from
|
|
890
|
+
// the live history stack. A desynced index makes onPopState's direction calc
|
|
891
|
+
// misfire, so Back skips an entry. On a persisted (bfcache) restore we re-seed
|
|
892
|
+
// the tracked index and the `originalLocation` "from" URL from the live state,
|
|
893
|
+
// and restore scroll — mirroring that seed. This is a no-op on a normal load
|
|
894
|
+
// (`persisted` falsy/absent) and idempotent (re-seeding from the same state
|
|
895
|
+
// twice changes nothing and fires no transition).
|
|
876
896
|
const onPageShow = (ev) => {
|
|
877
|
-
// Normal (non-bfcache) loads already ran
|
|
897
|
+
// Normal (non-bfcache) loads already ran init()'s seed; leave them be.
|
|
878
898
|
if (!ev.persisted)
|
|
879
899
|
return;
|
|
880
|
-
//
|
|
881
|
-
//
|
|
882
|
-
//
|
|
900
|
+
// init() seeds `originalLocation` and `currentHistoryIndex` together; re-sync
|
|
901
|
+
// both here so the next transition() gets the correct `from` URL (a stale
|
|
902
|
+
// `originalLocation` would feed onPopState the wrong origin).
|
|
883
903
|
originalLocation = new URL(location.href);
|
|
884
904
|
const index = history.state?.index;
|
|
885
905
|
if (Number.isFinite(index)) {
|
|
@@ -889,67 +909,116 @@ const onPageShow = (ev) => {
|
|
|
889
909
|
scrollTo({ left: history.state.scrollX, top: history.state.scrollY });
|
|
890
910
|
}
|
|
891
911
|
};
|
|
892
|
-
// initialization
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
intervalId = undefined;
|
|
932
|
-
onScrollEnd();
|
|
933
|
-
return;
|
|
934
|
-
}
|
|
935
|
-
else {
|
|
936
|
-
((lastY = scrollY), (lastX = scrollX));
|
|
937
|
-
}
|
|
938
|
-
};
|
|
939
|
-
// We can't know when or how often scroll events fire, so we'll just use them to start intervals
|
|
940
|
-
addEventListener("scroll", () => {
|
|
941
|
-
if (intervalId !== undefined)
|
|
942
|
-
return;
|
|
943
|
-
((lastIndex = history.state?.index), (lastY = scrollY), (lastX = scrollX));
|
|
944
|
-
intervalId = window.setInterval(scrollInterval, 50);
|
|
945
|
-
}, { passive: true });
|
|
912
|
+
// ---- initialization (folded out of module scope by #2436) ----
|
|
913
|
+
// Once-guard for the page-state phase of init(). Deliberately SEPARATE from the
|
|
914
|
+
// `initialized` latch below, because the two phases have different eligibility:
|
|
915
|
+
// this one runs on EVERY browser page — including one where view transitions
|
|
916
|
+
// are ineligible (no native support and fallback "none") — while the activation
|
|
917
|
+
// listeners are VT-gated. A single shared latch would either skip this phase
|
|
918
|
+
// forever after an ineligible early call, or latch activation on a page that
|
|
919
|
+
// never qualified for it. (#2436)
|
|
920
|
+
let pageStateSeeded = false;
|
|
921
|
+
/**
|
|
922
|
+
* Everything module evaluation used to do for EVERY browser page, regardless
|
|
923
|
+
* of view-transition eligibility: restore (or seed) this page's history entry
|
|
924
|
+
* and its scroll position, and mark the scripts the initial page load already
|
|
925
|
+
* executed so runScripts() does not re-run them after a swap.
|
|
926
|
+
*
|
|
927
|
+
* Called from init() BEFORE its eligibility early-return — that gating
|
|
928
|
+
* asymmetry is load-bearing and predates the fold: a page with fallback "none"
|
|
929
|
+
* still needs its history entry restored and its scripts marked. (#2436)
|
|
930
|
+
*/
|
|
931
|
+
function seedPageState() {
|
|
932
|
+
if (pageStateSeeded)
|
|
933
|
+
return;
|
|
934
|
+
pageStateSeeded = true;
|
|
935
|
+
if (history.state) {
|
|
936
|
+
// Here we reloaded a page with history state
|
|
937
|
+
// (e.g. history navigation from non-transition page or browser reload)
|
|
938
|
+
// Adopt only a finite index — a foreign pre-init history.state (e.g. a
|
|
939
|
+
// consumer's own replaceState({modal: true})) has no router index, and
|
|
940
|
+
// adopting undefined would stamp the next push with NaN. Same guard as
|
|
941
|
+
// ensureNavigationState() and onPageShow(). (#2436)
|
|
942
|
+
const index = history.state.index;
|
|
943
|
+
if (Number.isFinite(index))
|
|
944
|
+
currentHistoryIndex = index;
|
|
945
|
+
// Skip the scroll restore when an init-less navigate()/syncHistoryEntry()
|
|
946
|
+
// already ran on this page (navigationStateSeeded latched before init):
|
|
947
|
+
// the entry it pushed is stamped scrollX/scrollY 0, and a late-activating
|
|
948
|
+
// init() must not yank the already-scrolled viewport to the top. (#2436)
|
|
949
|
+
if (!navigationStateSeeded) {
|
|
950
|
+
scrollTo({ left: history.state.scrollX, top: history.state.scrollY });
|
|
946
951
|
}
|
|
947
952
|
}
|
|
953
|
+
else if (transitionEnabledOnThisPage()) {
|
|
954
|
+
// This page is loaded from the browser address bar or via a link from extern,
|
|
955
|
+
// it needs a state in the history
|
|
956
|
+
safeReplaceState({ index: currentHistoryIndex, scrollX, scrollY }, "");
|
|
957
|
+
history.scrollRestoration = "manual";
|
|
958
|
+
}
|
|
948
959
|
for (const script of document.getElementsByTagName("script")) {
|
|
949
960
|
detectScriptExecuted(script);
|
|
950
961
|
script.dataset["zfbExec"] = "";
|
|
951
962
|
}
|
|
952
963
|
}
|
|
964
|
+
/**
|
|
965
|
+
* Register the window-level navigation listeners. Called from init() only
|
|
966
|
+
* after the view-transition eligibility check, and only once — the
|
|
967
|
+
* `initialized` latch owns this phase.
|
|
968
|
+
*/
|
|
969
|
+
function registerNavigationListeners() {
|
|
970
|
+
addEventListener("popstate", onPopState);
|
|
971
|
+
addEventListener("load", onPageLoad);
|
|
972
|
+
// Re-sync the tracked history index + scroll on a WebKit bfcache restore;
|
|
973
|
+
// a no-op on normal loads. See onPageShow above.
|
|
974
|
+
addEventListener("pageshow", onPageShow);
|
|
975
|
+
// There's not a good way to record scroll position before a history back
|
|
976
|
+
// navigation, so we will record it when the user has stopped scrolling.
|
|
977
|
+
if ("onscrollend" in window)
|
|
978
|
+
addEventListener("scrollend", onScrollEnd);
|
|
979
|
+
else {
|
|
980
|
+
// Keep track of state between intervals
|
|
981
|
+
let intervalId, lastY, lastX, lastIndex;
|
|
982
|
+
const scrollInterval = () => {
|
|
983
|
+
// The interval can outlive its document context — a test-env teardown or a
|
|
984
|
+
// real page unload removes window/history before the 50ms tick. `typeof`
|
|
985
|
+
// never throws even when the global is gone: stop the interval and bail
|
|
986
|
+
// rather than dereferencing a torn-down global (the #1061 bug class; #1063).
|
|
987
|
+
// In a real browser these globals always exist, so this path is test-env
|
|
988
|
+
// only. clearInterval may itself be mid-teardown, so guard the call too.
|
|
989
|
+
if (typeof window === "undefined" || typeof history === "undefined") {
|
|
990
|
+
if (typeof clearInterval === "function")
|
|
991
|
+
clearInterval(intervalId);
|
|
992
|
+
intervalId = undefined;
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
// Check the index to see if a popstate event was fired
|
|
996
|
+
if (lastIndex !== history.state?.index) {
|
|
997
|
+
clearInterval(intervalId);
|
|
998
|
+
intervalId = undefined;
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
// Check if the user stopped scrolling
|
|
1002
|
+
if (lastY === scrollY && lastX === scrollX) {
|
|
1003
|
+
// Cancel the interval and update scroll positions
|
|
1004
|
+
clearInterval(intervalId);
|
|
1005
|
+
intervalId = undefined;
|
|
1006
|
+
onScrollEnd();
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
else {
|
|
1010
|
+
((lastY = scrollY), (lastX = scrollX));
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
// We can't know when or how often scroll events fire, so we'll just use them to start intervals
|
|
1014
|
+
addEventListener("scroll", () => {
|
|
1015
|
+
if (intervalId !== undefined)
|
|
1016
|
+
return;
|
|
1017
|
+
((lastIndex = history.state?.index), (lastY = scrollY), (lastX = scrollX));
|
|
1018
|
+
intervalId = window.setInterval(scrollInterval, 50);
|
|
1019
|
+
}, { passive: true });
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
953
1022
|
// ---- W3C3: click + form intercept, public idempotent init() ----
|
|
954
1023
|
// Returns true when the modifier-key combo or mouse button means "open in new tab / download".
|
|
955
1024
|
// Matches Astro's `leavesWindow` helper in ClientRouter.astro.
|
|
@@ -1046,26 +1115,44 @@ function handleSubmit(ev) {
|
|
|
1046
1115
|
ev.preventDefault();
|
|
1047
1116
|
navigate(action, options);
|
|
1048
1117
|
}
|
|
1049
|
-
// Guard flag — ensures
|
|
1050
|
-
//
|
|
1118
|
+
// Guard flag — ensures the activation listeners (popstate/load/pageshow/scroll
|
|
1119
|
+
// plus click + submit) are registered only once even if init() is called
|
|
1120
|
+
// multiple times (e.g. two <ClientRouter> mounts on the same page).
|
|
1051
1121
|
let initialized = false;
|
|
1052
1122
|
/**
|
|
1053
|
-
*
|
|
1054
|
-
*
|
|
1123
|
+
* Bootstrap the client router on this page: restore its history entry, mark
|
|
1124
|
+
* the already-executed scripts, and wire up the navigation, click and
|
|
1125
|
+
* form-submit listeners. Safe to call multiple times — each of the two phases
|
|
1126
|
+
* runs at most once (idempotent).
|
|
1127
|
+
*
|
|
1128
|
+
* Evaluating this module does nothing; every side effect the router performs
|
|
1129
|
+
* at startup happens here (#2436). Two once-guards rather than one, because
|
|
1130
|
+
* the phases have different eligibility — see `pageStateSeeded`.
|
|
1055
1131
|
*
|
|
1056
1132
|
* @param _options - Forward-compat hook matching Astro's init() signature. Ignored in v1.
|
|
1057
1133
|
*/
|
|
1058
1134
|
export function init(_options) {
|
|
1059
|
-
if (initialized)
|
|
1060
|
-
return;
|
|
1061
|
-
// Latch the guard only AFTER the early-return guards below: an ineligible
|
|
1062
|
-
// early call (no browser, or fallback "none") must not permanently latch
|
|
1063
|
-
// `initialized`, or a later legitimately-eligible init() would no-op forever.
|
|
1064
1135
|
if (!inBrowser)
|
|
1065
1136
|
return;
|
|
1137
|
+
// Phase 1 — runs on every browser page, even a view-transition-ineligible
|
|
1138
|
+
// one, and therefore BEFORE the eligibility early-return below.
|
|
1139
|
+
seedPageState();
|
|
1140
|
+
if (initialized)
|
|
1141
|
+
return;
|
|
1142
|
+
// Phase 2 — activation. Latch the guard only AFTER this eligibility check:
|
|
1143
|
+
// an ineligible early call (fallback "none" and no native view transitions)
|
|
1144
|
+
// must not permanently latch `initialized`, or a later legitimately-eligible
|
|
1145
|
+
// init() would no-op forever.
|
|
1066
1146
|
if (!supportsViewTransitions && getFallback() === "none")
|
|
1067
1147
|
return;
|
|
1068
1148
|
initialized = true;
|
|
1149
|
+
// Where we came from, for the first transition() of this page. Re-seeded
|
|
1150
|
+
// unconditionally at activation (as module evaluation used to do), so it
|
|
1151
|
+
// reflects the URL at activation time even if an init-less navigate() or
|
|
1152
|
+
// syncHistoryEntry() already primed it via ensureNavigationState().
|
|
1153
|
+
navigationStateSeeded = true;
|
|
1154
|
+
originalLocation = new URL(location.href);
|
|
1155
|
+
registerNavigationListeners();
|
|
1069
1156
|
document.addEventListener("click", handleClick);
|
|
1070
1157
|
document.addEventListener("submit", handleSubmit);
|
|
1071
1158
|
// Prefetch hook intentionally omitted from v1 — see https://github.com/zudolab/zudo-doc/issues/1527
|