@lesto/ui 0.1.0
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/package.json +58 -0
- package/src/bfcache.ts +121 -0
- package/src/client.ts +54 -0
- package/src/data-client.ts +361 -0
- package/src/data-resolve.tsx +120 -0
- package/src/data.ts +183 -0
- package/src/define-island.tsx +222 -0
- package/src/errors.ts +36 -0
- package/src/hydrate.tsx +563 -0
- package/src/index.ts +165 -0
- package/src/island.ts +258 -0
- package/src/link.tsx +103 -0
- package/src/metadata.ts +195 -0
- package/src/mount.ts +99 -0
- package/src/node.ts +8 -0
- package/src/props.ts +95 -0
- package/src/registry.ts +141 -0
- package/src/render.tsx +349 -0
- package/src/resources.ts +233 -0
- package/src/route.ts +62 -0
- package/src/routes.ts +101 -0
- package/src/schema.ts +244 -0
- package/src/serialize.ts +148 -0
- package/src/server-preact.ts +59 -0
- package/src/server.ts +44 -0
- package/src/softnav-contract.ts +144 -0
- package/src/softnav.ts +934 -0
- package/src/stream.tsx +387 -0
- package/src/types.ts +46 -0
- package/src/validate.ts +107 -0
package/src/softnav.ts
ADDED
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side soft navigation — a `Link` is just an `<a>`, and this upgrades it.
|
|
3
|
+
*
|
|
4
|
+
* Lesto pages are real documents: a plain `<a href>` works with JS off, which is
|
|
5
|
+
* the floor this never drops below. With JS on, {@link enableSoftNav} installs ONE
|
|
6
|
+
* delegated click listener that, for an eligible same-origin link, fetches the
|
|
7
|
+
* next page, swaps its body in place, re-hydrates the islands, and updates history
|
|
8
|
+
* — no full document reload, no white flash, island state preserved where the swap
|
|
9
|
+
* can keep it. Back/Forward replays the same swap from `popstate`, and scroll is
|
|
10
|
+
* restored to where the user left each entry.
|
|
11
|
+
*
|
|
12
|
+
* This is the SPA-grade soft-nav primitive, distinct from the roadmap's Bet I
|
|
13
|
+
* `@lesto/platform` view-transitions / speculation-rules item (which layers
|
|
14
|
+
* browser-native niceties ON TOP of a navigation): this is the navigation itself,
|
|
15
|
+
* the fetch-and-swap every peer framework's client router owns (Next's app-router
|
|
16
|
+
* Link, RR7, SvelteKit, Nuxt, TanStack, Astro's `<ClientRouter>`). Bet I's
|
|
17
|
+
* view-transition wrapper is a natural future caller of {@link SoftNavOptions.swap}.
|
|
18
|
+
*
|
|
19
|
+
* ## Progressive enhancement is the contract, not a mode
|
|
20
|
+
*
|
|
21
|
+
* The server still renders every page as a full document, and a `Link` is an
|
|
22
|
+
* ordinary anchor — so with no JS (or before this module loads, or for any link
|
|
23
|
+
* this declines to handle) the browser does a normal navigation. Soft nav is a
|
|
24
|
+
* pure enhancement layered over working links; it never becomes load-bearing for
|
|
25
|
+
* correctness, only for smoothness. A link opts OUT with `data-lesto-reload`
|
|
26
|
+
* (force a full nav) and is declined automatically when it is cross-origin, a
|
|
27
|
+
* download, targets another frame, or the click carries a modifier (the user asked
|
|
28
|
+
* for a new tab) — every case where a soft swap would be wrong.
|
|
29
|
+
*
|
|
30
|
+
* ## bfcache stays intact
|
|
31
|
+
*
|
|
32
|
+
* Soft nav drives `history.pushState`, which does NOT enter the back/forward cache
|
|
33
|
+
* (only real document navigations do), so this never fights {@link observePageLifecycle}
|
|
34
|
+
* (`bfcache.ts`): that helper still owns the page lifecycle and still refuses
|
|
35
|
+
* `unload`/`beforeunload`. A `popstate` to a soft-nav entry re-fetches and swaps;
|
|
36
|
+
* a `popstate` that the browser served from bfcache (a real prior document) fires
|
|
37
|
+
* `pageshow` with `persisted`, the lifecycle helper's job, untouched here.
|
|
38
|
+
*
|
|
39
|
+
* ## Everything that touches the platform is injected
|
|
40
|
+
*
|
|
41
|
+
* `fetch`, `history`, the document, the window's scroll, and the re-hydrate call
|
|
42
|
+
* are all seams with real-browser defaults, so the whole state machine runs under
|
|
43
|
+
* jsdom with no real navigation, no real network, and a fake history — the same
|
|
44
|
+
* testability discipline as `hydrate.tsx` and `bfcache.ts`.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { UiError } from "./errors";
|
|
48
|
+
import { hydrateDocumentIslands } from "./hydrate";
|
|
49
|
+
import type { HydrateOptions, HydrationResult } from "./hydrate";
|
|
50
|
+
import type { Registry } from "./registry";
|
|
51
|
+
import { eligibleAnchor, LAYOUT_ATTR, PREFETCH_ATTR, RELOAD_ATTR } from "./softnav-contract";
|
|
52
|
+
import type { PrefetchStrategy, SoftNavAnchor, SoftNavClick } from "./softnav-contract";
|
|
53
|
+
|
|
54
|
+
// Re-export the DOM-free contract through the runtime barrel, so a caller that
|
|
55
|
+
// reaches for the soft-nav surface sees one module — the constants and the click /
|
|
56
|
+
// anchor shapes that `<Link>` (isomorphic) and this runtime (browser) both read.
|
|
57
|
+
export { eligibleAnchor, LAYOUT_ATTR, PREFETCH_ATTR, RELOAD_ATTR } from "./softnav-contract";
|
|
58
|
+
export type { PrefetchStrategy, SoftNavAnchor, SoftNavClick } from "./softnav-contract";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One history entry's restorable scroll position. Saved into `history.state` on
|
|
62
|
+
* navigate-away so Back/Forward returns the user to where they were, not the top.
|
|
63
|
+
*/
|
|
64
|
+
export interface ScrollPosition {
|
|
65
|
+
x: number;
|
|
66
|
+
y: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The history surface soft nav drives — `window.history` satisfies it. */
|
|
70
|
+
export interface SoftNavHistory {
|
|
71
|
+
state: unknown;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The browser's scroll-restoration mode. Always present on a real `History`
|
|
75
|
+
* (`"auto"` by default), so it is REQUIRED here too — soft nav flips it to
|
|
76
|
+
* `"manual"` for the session and hands the prior value back on `disable()`,
|
|
77
|
+
* with no "was it ever set" branch to reason about.
|
|
78
|
+
*/
|
|
79
|
+
scrollRestoration: string;
|
|
80
|
+
|
|
81
|
+
pushState(state: unknown, unused: string, url: string): void;
|
|
82
|
+
replaceState(state: unknown, unused: string, url: string): void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The window surface soft nav reads/writes for scroll — `window` satisfies it. */
|
|
86
|
+
export interface SoftNavWindow {
|
|
87
|
+
scrollX: number;
|
|
88
|
+
scrollY: number;
|
|
89
|
+
scrollTo(x: number, y: number): void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Where the `popstate` (Back/Forward) listener attaches — `window` satisfies it.
|
|
94
|
+
* Its own seam, separate from the scroll {@link SoftNavWindow}, so a test can fire
|
|
95
|
+
* a synthetic `popstate` at a fake target with no real history navigation.
|
|
96
|
+
*/
|
|
97
|
+
export interface PopStateTarget {
|
|
98
|
+
addEventListener(type: "popstate", listener: (event: Event) => void): void;
|
|
99
|
+
removeEventListener(type: "popstate", listener: (event: Event) => void): void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What soft nav fetches: a page's HTML and the URL it actually resolved to (after
|
|
104
|
+
* any redirect), so the history entry reflects where the user really landed.
|
|
105
|
+
*/
|
|
106
|
+
export interface FetchedPage {
|
|
107
|
+
html: string;
|
|
108
|
+
url: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The page fetcher — defaults to a same-origin `fetch` of the destination's HTML.
|
|
113
|
+
* Always receives the navigation's `AbortSignal` so a superseded in-flight fetch
|
|
114
|
+
* can be cancelled (the default passes it straight to `fetch`); an override that
|
|
115
|
+
* does not care about cancellation simply omits the parameter.
|
|
116
|
+
*/
|
|
117
|
+
export type PageFetcher = (url: string, signal: AbortSignal) => Promise<FetchedPage>;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Swap the fetched document's body into the live one and return the new title.
|
|
121
|
+
*
|
|
122
|
+
* The default {@link defaultSwap} parses the HTML, swaps the body — LAYOUT-PRESERVING
|
|
123
|
+
* when the document carries {@link LAYOUT_ATTR} markers, else replacing the whole
|
|
124
|
+
* body's contents — and returns the fetched `<title>`. A caller can inject a finer
|
|
125
|
+
* swap (a single content region, a view-transition wrapper for Bet I) without this
|
|
126
|
+
* module knowing how the page is structured.
|
|
127
|
+
*/
|
|
128
|
+
export type PageSwapper = (html: string, doc: Document) => string | undefined;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The re-hydrate call after a swap — defaults to {@link hydrateDocumentIslands}.
|
|
132
|
+
*
|
|
133
|
+
* Its shape is exactly `hydrateDocumentIslands`'s `(registry, options)`, so the
|
|
134
|
+
* default IS that function with no adapter, and the applier passes the swap target
|
|
135
|
+
* document as `options.root` (the seam hydration looks islands up under). An
|
|
136
|
+
* override that ignores `options` still type-checks; one that honors `root`
|
|
137
|
+
* re-hydrates against the just-swapped document, not the ambient global one.
|
|
138
|
+
*/
|
|
139
|
+
export type Rehydrate = (registry: Registry, options: HydrateOptions) => HydrationResult;
|
|
140
|
+
|
|
141
|
+
/** A soft navigation's kind — a forward push vs. a Back/Forward replay. */
|
|
142
|
+
export type SoftNavKind = "push" | "pop";
|
|
143
|
+
|
|
144
|
+
/** What a completed soft navigation reports to {@link SoftNavOptions.onNavigate}. */
|
|
145
|
+
export interface SoftNavEvent {
|
|
146
|
+
kind: SoftNavKind;
|
|
147
|
+
url: string;
|
|
148
|
+
hydration: HydrationResult;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The injectable seams + hooks `enableSoftNav` runs on; all default to the real browser. */
|
|
152
|
+
export interface SoftNavOptions {
|
|
153
|
+
/** Where the delegated click listener attaches and islands re-hydrate. Defaults to `document`. */
|
|
154
|
+
document?: Document;
|
|
155
|
+
|
|
156
|
+
/** The history to drive. Defaults to `window.history`. */
|
|
157
|
+
history?: SoftNavHistory;
|
|
158
|
+
|
|
159
|
+
/** The window to read/write scroll on. Defaults to `window`. */
|
|
160
|
+
window?: SoftNavWindow;
|
|
161
|
+
|
|
162
|
+
/** Where the `popstate` listener attaches. Defaults to the document's `defaultView` (the window). */
|
|
163
|
+
popStateTarget?: PopStateTarget;
|
|
164
|
+
|
|
165
|
+
/** How to fetch a page's HTML. Defaults to a same-origin `fetch`. */
|
|
166
|
+
fetchPage?: PageFetcher;
|
|
167
|
+
|
|
168
|
+
/** How to swap the fetched body in. Defaults to replacing `document.body`'s contents. */
|
|
169
|
+
swap?: PageSwapper;
|
|
170
|
+
|
|
171
|
+
/** How to re-hydrate after a swap. Defaults to {@link hydrateDocumentIslands}. */
|
|
172
|
+
rehydrate?: Rehydrate;
|
|
173
|
+
|
|
174
|
+
/** Called after each successful soft navigation — the Bet I view-transition hook, telemetry, etc. */
|
|
175
|
+
onNavigate?: (event: SoftNavEvent) => void;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Called the moment a soft navigation STARTS — before its fetch, after the click
|
|
179
|
+
* is taken over. The pair to {@link onNavigate} (which fires after success): this
|
|
180
|
+
* is the cue to show pending UI (a top progress bar, a busy cursor). It carries
|
|
181
|
+
* the destination and {@link SoftNavKind} so a caller can distinguish a forward
|
|
182
|
+
* push from a Back/Forward replay. Fires once per navigation, including one a
|
|
183
|
+
* newer click later supersedes (so a started-but-aborted nav is observable too).
|
|
184
|
+
*/
|
|
185
|
+
onNavigateStart?: (event: SoftNavStart) => void;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Observe the {@link IsNavigatingSignal} pending flag — called once immediately
|
|
189
|
+
* with the current value (false) and again on every change, so a caller can drive
|
|
190
|
+
* pending UI declaratively without tracking start/end itself. The observable form
|
|
191
|
+
* of {@link onNavigateStart} + {@link onNavigate}; both are wired off the same
|
|
192
|
+
* in-flight count, so they never disagree. (The same signal is returned from
|
|
193
|
+
* {@link enableSoftNav} for a caller that prefers to read it.)
|
|
194
|
+
*/
|
|
195
|
+
onNavigatingChange?: (navigating: boolean) => void;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* How to observe a prefetch link entering the viewport — defaults to the browser's
|
|
199
|
+
* `IntersectionObserver`. Injected so the viewport-prefetch path is testable under
|
|
200
|
+
* jsdom (which has no real `IntersectionObserver`) with a fake that fires entries
|
|
201
|
+
* on demand. A `"viewport"` `<Link prefetch>` needs this; an environment that
|
|
202
|
+
* lacks it (and supplies no override) refuses viewport prefetch with a coded
|
|
203
|
+
* `UI_SOFTNAV_PREFETCH_UNSUPPORTED`, leaving the link's hover/click paths intact.
|
|
204
|
+
*/
|
|
205
|
+
intersectionObserver?: IntersectionObserverFactory;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Called when a soft navigation's fetch or swap throws. The DEFAULT recovers by
|
|
209
|
+
* doing a real navigation to the destination (`assign`), so a soft-nav failure
|
|
210
|
+
* degrades to exactly the full reload the link would have done with no JS —
|
|
211
|
+
* never a dead link. Override to surface the error differently.
|
|
212
|
+
*/
|
|
213
|
+
onError?: (error: unknown, url: string) => void;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** What a STARTING soft navigation reports to {@link SoftNavOptions.onNavigateStart}. */
|
|
217
|
+
export interface SoftNavStart {
|
|
218
|
+
kind: SoftNavKind;
|
|
219
|
+
url: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The observable "a navigation is in flight" flag returned from {@link enableSoftNav}.
|
|
224
|
+
*
|
|
225
|
+
* `get()` reads the current value; `subscribe(listener)` registers a listener called
|
|
226
|
+
* immediately with the current value and again on every change, returning an
|
|
227
|
+
* unsubscribe. A thin hand-rolled signal (no dependency): an app wires it to whatever
|
|
228
|
+
* pending UI it renders — a progress bar, a `aria-busy` flag, a disabled nav.
|
|
229
|
+
*/
|
|
230
|
+
export interface IsNavigatingSignal {
|
|
231
|
+
get(): boolean;
|
|
232
|
+
subscribe(listener: (navigating: boolean) => void): () => void;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Construct an {@link IntersectionObserver}-shaped observer — the seam the viewport
|
|
237
|
+
* prefetch path uses. The real `IntersectionObserver` constructor satisfies it; a
|
|
238
|
+
* test injects a fake whose callback it can fire with synthetic entries.
|
|
239
|
+
*/
|
|
240
|
+
export type IntersectionObserverFactory = (
|
|
241
|
+
callback: (entries: IntersectionObserverEntry[]) => void,
|
|
242
|
+
) => IntersectionObserverLike;
|
|
243
|
+
|
|
244
|
+
/** The slice of `IntersectionObserver` the prefetch wiring uses. */
|
|
245
|
+
export interface IntersectionObserverLike {
|
|
246
|
+
observe(target: Element): void;
|
|
247
|
+
unobserve(target: Element): void;
|
|
248
|
+
disconnect(): void;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The control surface {@link enableSoftNav} returns: a callable that detaches every
|
|
253
|
+
* listener (idempotent — a second call is a harmless no-op), plus the
|
|
254
|
+
* {@link IsNavigatingSignal} pending flag a caller can read or subscribe to. It IS a
|
|
255
|
+
* function (call it to disable), so the original `disable()` call site is unchanged.
|
|
256
|
+
*/
|
|
257
|
+
export interface DisableSoftNav {
|
|
258
|
+
(): void;
|
|
259
|
+
|
|
260
|
+
/** The observable "a navigation is in flight" flag — see {@link IsNavigatingSignal}. */
|
|
261
|
+
isNavigating: IsNavigatingSignal;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Resolve the {@link SoftNavAnchor} a click targets by walking from the clicked
|
|
266
|
+
* node up to the nearest enclosing `<a href>`, or `undefined` if there is none.
|
|
267
|
+
*
|
|
268
|
+
* A click lands on whatever was under the cursor — a `<span>` inside a `<Link>`,
|
|
269
|
+
* the `<a>` itself, or bare page chrome — so we ascend the ancestry via the
|
|
270
|
+
* standard `closest("a[href]")` (which includes the element itself) and read the
|
|
271
|
+
* eligibility surface off the found anchor: its RESOLVED `href` (the DOM
|
|
272
|
+
* normalizes a relative one to absolute), its `target`, whether it carries a
|
|
273
|
+
* `download`, and the {@link RELOAD_ATTR} opt-out. An anchor with no `href`, or a
|
|
274
|
+
* click on non-anchor chrome, yields `undefined` and the click falls through to
|
|
275
|
+
* the browser untouched.
|
|
276
|
+
*/
|
|
277
|
+
function resolveAnchor(target: EventTarget | null): SoftNavAnchor | undefined {
|
|
278
|
+
// Only an `Element` can have an enclosing anchor; a click whose target is not
|
|
279
|
+
// one (the document, a text node the platform never hands us) has no anchor.
|
|
280
|
+
if (!(target instanceof Element)) return undefined;
|
|
281
|
+
|
|
282
|
+
const anchor = target.closest("a[href]");
|
|
283
|
+
|
|
284
|
+
if (!(anchor instanceof HTMLAnchorElement)) return undefined;
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
href: anchor.href,
|
|
288
|
+
target: anchor.target,
|
|
289
|
+
hasDownload: anchor.hasAttribute("download"),
|
|
290
|
+
reload: anchor.hasAttribute(RELOAD_ATTR),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The prefetch-eligible anchor a node sits under, with its opted-into strategy — or
|
|
296
|
+
* `undefined` when there is no enclosing `<a>` carrying a valid {@link PREFETCH_ATTR}.
|
|
297
|
+
*
|
|
298
|
+
* Reads the nearest anchor (a hover may land on a child `<span>`) so the intent
|
|
299
|
+
* listener and any strategy check share one resolution. Pure over the DOM, captures
|
|
300
|
+
* nothing — a sibling of {@link resolveAnchor}.
|
|
301
|
+
*/
|
|
302
|
+
function prefetchTargetOf(
|
|
303
|
+
target: EventTarget | null,
|
|
304
|
+
): { anchor: HTMLAnchorElement; strategy: PrefetchStrategy } | undefined {
|
|
305
|
+
if (!(target instanceof Element)) return undefined;
|
|
306
|
+
|
|
307
|
+
const anchor = target.closest("a[href]");
|
|
308
|
+
|
|
309
|
+
if (!(anchor instanceof HTMLAnchorElement)) return undefined;
|
|
310
|
+
|
|
311
|
+
const value = anchor.getAttribute(PREFETCH_ATTR);
|
|
312
|
+
|
|
313
|
+
return value === "viewport" || value === "hover" ? { anchor, strategy: value } : undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The default page fetch: a same-origin GET whose body is the page's HTML. The
|
|
318
|
+
* resolved `response.url` rides back so a redirect lands the history entry on the
|
|
319
|
+
* real destination, not the link's pre-redirect href.
|
|
320
|
+
*/
|
|
321
|
+
const defaultFetchPage: PageFetcher = async (url, signal) => {
|
|
322
|
+
const response = await fetch(url, {
|
|
323
|
+
credentials: "same-origin",
|
|
324
|
+
headers: { accept: "text/html" },
|
|
325
|
+
signal,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// A non-ok page is still a page (a 404 has a body to show); we swap it like any
|
|
329
|
+
// other rather than throw, so the user sees the server's error page, not a dead
|
|
330
|
+
// click. A true network failure rejects and routes to `onError` → full nav.
|
|
331
|
+
const html = await response.text();
|
|
332
|
+
|
|
333
|
+
return { html, url: response.url === "" ? url : response.url };
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Replace a live element's CONTENTS with a fetched element's children, importing
|
|
338
|
+
* the fetched nodes into the live document. Shared by the full-body swap and the
|
|
339
|
+
* partial layout swap so the import-and-replace rule lives in one place.
|
|
340
|
+
*/
|
|
341
|
+
function replaceContents(live: Element, fetched: Element, doc: Document): void {
|
|
342
|
+
live.replaceChildren(...Array.from(fetched.childNodes).map((node) => doc.importNode(node, true)));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The deepest layout-marker subtree the live + fetched documents SHARE, or
|
|
347
|
+
* `undefined` when they share none (no markers, or the chains diverge at the root).
|
|
348
|
+
*
|
|
349
|
+
* Layout-preserving partial swap (driven by {@link LAYOUT_ATTR}): the server marks
|
|
350
|
+
* each layout-boundary element with its `data-lesto-layout="<depth>"`. We walk the
|
|
351
|
+
* live document's marker chain from the outermost depth inward, matching each
|
|
352
|
+
* against the fetched document's marker at the same depth. As long as a depth marker
|
|
353
|
+
* exists in BOTH (the two pages pass through the same layout there), the swap can
|
|
354
|
+
* keep that layout's live DOM mounted and recurse inward. The first depth that is
|
|
355
|
+
* missing on either side is where the pages diverge — everything from there down is
|
|
356
|
+
* what must be swapped.
|
|
357
|
+
*
|
|
358
|
+
* Returns the matched `{ live, fetched }` element pair for the deepest shared
|
|
359
|
+
* layout, so the caller swaps only THAT element's contents (the inner page +
|
|
360
|
+
* deeper layouts), preserving every outer layout above it. `undefined` means
|
|
361
|
+
* "no shared layout boundary" — the caller does the whole-body fallback swap.
|
|
362
|
+
*/
|
|
363
|
+
function deepestSharedLayout(
|
|
364
|
+
liveBody: Element,
|
|
365
|
+
fetchedBody: Element,
|
|
366
|
+
): { live: Element; fetched: Element } | undefined {
|
|
367
|
+
let live = liveBody.querySelector(`[${LAYOUT_ATTR}="0"]`);
|
|
368
|
+
let fetched = fetchedBody.querySelector(`[${LAYOUT_ATTR}="0"]`);
|
|
369
|
+
|
|
370
|
+
// No depth-0 marker in both documents → no shared layout boundary at all; the
|
|
371
|
+
// caller falls back to a full body swap (today's behavior, never a regression).
|
|
372
|
+
if (live === null || fetched === null) return undefined;
|
|
373
|
+
|
|
374
|
+
let match: { live: Element; fetched: Element } = { live, fetched };
|
|
375
|
+
|
|
376
|
+
// Descend the marker chain depth by depth. The next-deeper layout, if both pages
|
|
377
|
+
// have it, must be NESTED inside the current matched layout (a prefix tree), so we
|
|
378
|
+
// look for it WITHIN the matched element — that keeps an unrelated sibling layout
|
|
379
|
+
// at the same depth elsewhere in the tree from being mistaken for a match.
|
|
380
|
+
for (let depth = 1; ; depth += 1) {
|
|
381
|
+
const selector = `[${LAYOUT_ATTR}="${depth}"]`;
|
|
382
|
+
|
|
383
|
+
live = match.live.querySelector(selector);
|
|
384
|
+
fetched = match.fetched.querySelector(selector);
|
|
385
|
+
|
|
386
|
+
// One side lacks this depth → the chains diverge here; the previous match is the
|
|
387
|
+
// deepest layout both pages share.
|
|
388
|
+
if (live === null || fetched === null) return match;
|
|
389
|
+
|
|
390
|
+
match = { live, fetched };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The default swap: parse the fetched HTML and swap the live `<body>`.
|
|
396
|
+
*
|
|
397
|
+
* **Layout-preserving when it can be.** If both the live and fetched documents
|
|
398
|
+
* carry {@link LAYOUT_ATTR} markers that share an outer prefix, only the DEEPEST
|
|
399
|
+
* shared layout's contents are replaced (its inner page + any deeper layouts),
|
|
400
|
+
* leaving every outer layout's DOM — and the island state mounted in it — intact
|
|
401
|
+
* across the navigation. This is the "nested layouts keep their state" behavior.
|
|
402
|
+
*
|
|
403
|
+
* **Full-body fallback otherwise.** With no markers (today's server output) or a
|
|
404
|
+
* chain that diverges at the root, it replaces the whole `<body>`'s children — the
|
|
405
|
+
* original behavior, byte-for-byte, so the partial swap is a pure enhancement that
|
|
406
|
+
* never regresses an unmarked page.
|
|
407
|
+
*
|
|
408
|
+
* Either way it replaces CONTENTS (not the `<body>`/layout element itself), keeping
|
|
409
|
+
* the live nodes — and the click listener delegated to `<body>`'s document — attached
|
|
410
|
+
* across the swap. The head is left alone (the client module + styles already ran);
|
|
411
|
+
* only the per-page `<title>` is carried over.
|
|
412
|
+
*/
|
|
413
|
+
const defaultSwap: PageSwapper = (html, doc) => {
|
|
414
|
+
const parsed = new DOMParser().parseFromString(html, "text/html");
|
|
415
|
+
|
|
416
|
+
const shared = deepestSharedLayout(doc.body, parsed.body);
|
|
417
|
+
|
|
418
|
+
if (shared === undefined) {
|
|
419
|
+
// No shared layout boundary → swap the whole body (the original behavior).
|
|
420
|
+
replaceContents(doc.body, parsed.body, doc);
|
|
421
|
+
} else {
|
|
422
|
+
// A shared outer layout → swap only its inner contents, preserving the outer
|
|
423
|
+
// layout DOM (and any island state mounted in it) across the navigation.
|
|
424
|
+
replaceContents(shared.live, shared.fetched, doc);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// `textContent` is "" when the fetched page has no <title>; treat that as "no
|
|
428
|
+
// title to set" so we never blank the tab on a title-less page.
|
|
429
|
+
const title = parsed.title;
|
|
430
|
+
|
|
431
|
+
return title === "" ? undefined : title;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
/** The id of the framework-owned `aria-live` region soft nav announces routes through. */
|
|
435
|
+
const LIVE_REGION_ID = "lesto-route-announcer";
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Announce the new route to assistive tech. A real navigation re-reads the page;
|
|
439
|
+
* a body swap is silent, so we mirror that by writing the new title into a polite
|
|
440
|
+
* `aria-live` region (lazily created, visually hidden) — the same technique every
|
|
441
|
+
* peer client router uses to keep soft nav from regressing screen-reader UX.
|
|
442
|
+
*
|
|
443
|
+
* The region is appended to `<html>` (`documentElement`), a SIBLING of `<body>`, not
|
|
444
|
+
* inside the body — because the default swap does `body.replaceChildren(...)`, which
|
|
445
|
+
* would delete a body-resident region on the very next navigation. Screen readers
|
|
446
|
+
* only announce a region whose text changes while it is ALREADY in the DOM, so it
|
|
447
|
+
* must persist across swaps: by living on `<html>` it survives every body swap, is
|
|
448
|
+
* found again on the next navigation, and only its `textContent` updates.
|
|
449
|
+
*/
|
|
450
|
+
function announceRoute(message: string, doc: Document): void {
|
|
451
|
+
let region = doc.getElementById(LIVE_REGION_ID);
|
|
452
|
+
|
|
453
|
+
if (region === null) {
|
|
454
|
+
region = doc.createElement("div");
|
|
455
|
+
region.id = LIVE_REGION_ID;
|
|
456
|
+
region.setAttribute("aria-live", "polite");
|
|
457
|
+
region.setAttribute("aria-atomic", "true");
|
|
458
|
+
region.setAttribute("role", "status");
|
|
459
|
+
region.style.cssText =
|
|
460
|
+
"position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap";
|
|
461
|
+
doc.documentElement.append(region);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
region.textContent = message;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Move focus to the new page's top, like a real navigation does. We target the
|
|
469
|
+
* main landmark, else the first `<h1>`, else the body — giving a non-interactive
|
|
470
|
+
* target a `tabindex="-1"` (harmless: `-1` takes focus programmatically without
|
|
471
|
+
* entering the tab order) so the next Tab and the AT reading cursor start at the
|
|
472
|
+
* new content, not a detached old node.
|
|
473
|
+
*/
|
|
474
|
+
function focusMain(doc: Document): void {
|
|
475
|
+
const target =
|
|
476
|
+
doc.querySelector<HTMLElement>("main") ?? doc.querySelector<HTMLElement>("h1") ?? doc.body;
|
|
477
|
+
|
|
478
|
+
if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1");
|
|
479
|
+
|
|
480
|
+
target.focus();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* A minimal observable boolean — the backing of {@link IsNavigatingSignal}.
|
|
485
|
+
*
|
|
486
|
+
* A hand-rolled signal (no framework dependency): `set` no-ops when the value is
|
|
487
|
+
* unchanged so listeners only hear real transitions, and `subscribe` calls back
|
|
488
|
+
* immediately with the current value (the "initial render" any pending-UI binding
|
|
489
|
+
* needs) then on every change. Listeners live in a `Set` so an unsubscribe is exact.
|
|
490
|
+
*/
|
|
491
|
+
function createBooleanSignal(): IsNavigatingSignal & { set(next: boolean): void } {
|
|
492
|
+
let value = false;
|
|
493
|
+
const listeners = new Set<(navigating: boolean) => void>();
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
get: () => value,
|
|
497
|
+
set(next: boolean): void {
|
|
498
|
+
if (next === value) return;
|
|
499
|
+
|
|
500
|
+
value = next;
|
|
501
|
+
|
|
502
|
+
for (const listener of listeners) listener(value);
|
|
503
|
+
},
|
|
504
|
+
subscribe(listener: (navigating: boolean) => void): () => void {
|
|
505
|
+
listeners.add(listener);
|
|
506
|
+
|
|
507
|
+
listener(value);
|
|
508
|
+
|
|
509
|
+
return () => {
|
|
510
|
+
listeners.delete(listener);
|
|
511
|
+
};
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** One warmed prefetch: its in-flight fetch + the controller that can cancel it. */
|
|
517
|
+
interface PrefetchEntry {
|
|
518
|
+
promise: Promise<FetchedPage>;
|
|
519
|
+
controller: AbortController;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Enable client-side soft navigation: upgrade eligible same-origin link clicks to
|
|
524
|
+
* a fetch-and-swap, wire Back/Forward, restore scroll, and (opt-in per link) PREFETCH
|
|
525
|
+
* a destination before the click — over the supplied island `registry`, so a
|
|
526
|
+
* swapped-in page's islands hydrate with the same components the initial load did.
|
|
527
|
+
*
|
|
528
|
+
* Returns a `disable()` that removes every listener and restores the browser's own
|
|
529
|
+
* scroll restoration — so a test (or a hot reload) can tear the whole thing down
|
|
530
|
+
* cleanly. The returned function also carries an `isNavigating` {@link IsNavigatingSignal}
|
|
531
|
+
* for pending UI. Call it once, after the initial `hydrateDocumentIslands`:
|
|
532
|
+
*
|
|
533
|
+
* hydrateDocumentIslands(registry);
|
|
534
|
+
* const softNav = enableSoftNav(registry);
|
|
535
|
+
* softNav.isNavigating.subscribe((busy) => …); // drive a progress bar
|
|
536
|
+
* // softNav(); // later, to tear down
|
|
537
|
+
*/
|
|
538
|
+
export function enableSoftNav(registry: Registry, options: SoftNavOptions = {}): DisableSoftNav {
|
|
539
|
+
const doc: Document = options.document ?? document;
|
|
540
|
+
const hist: SoftNavHistory = options.history ?? window.history;
|
|
541
|
+
const win: SoftNavWindow = options.window ?? window;
|
|
542
|
+
|
|
543
|
+
const fetchPage: PageFetcher = options.fetchPage ?? defaultFetchPage;
|
|
544
|
+
const swap: PageSwapper = options.swap ?? defaultSwap;
|
|
545
|
+
const rehydrate: Rehydrate = options.rehydrate ?? hydrateDocumentIslands;
|
|
546
|
+
|
|
547
|
+
// We own scroll restoration: the browser's automatic restore races our swap (it
|
|
548
|
+
// restores against the OLD document height before the new body is in), so we set
|
|
549
|
+
// it manual and restore explicitly after each swap. Remembered so `disable()`
|
|
550
|
+
// hands it back.
|
|
551
|
+
const priorScrollRestoration = hist.scrollRestoration;
|
|
552
|
+
hist.scrollRestoration = "manual";
|
|
553
|
+
|
|
554
|
+
// The same-origin gate the eligibility check defers to us: a link to another
|
|
555
|
+
// origin is a real navigation, never a same-document swap. Read from the current
|
|
556
|
+
// document's URL so it is correct under jsdom's configured location too.
|
|
557
|
+
const origin = new URL(doc.URL).origin;
|
|
558
|
+
|
|
559
|
+
// Recover from a failed soft nav by doing the real navigation — the link's own
|
|
560
|
+
// floor. Pulled out so the default is testable and an override is a clean swap.
|
|
561
|
+
const onError =
|
|
562
|
+
options.onError ??
|
|
563
|
+
((_error: unknown, url: string): void => {
|
|
564
|
+
doc.location.assign(url);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// Last-CLICK-wins, not last-fetch-wins: every navigation grabs a monotonic token
|
|
568
|
+
// and aborts the prior in-flight fetch, and each `await` resume bails if a newer
|
|
569
|
+
// navigation (a forward click OR a Back/Forward pop) has since started — so two
|
|
570
|
+
// overlapping navigations can never resolve out of click order and corrupt the
|
|
571
|
+
// live body, the URL, or history.
|
|
572
|
+
let currentToken = 0;
|
|
573
|
+
let inFlight: AbortController | undefined;
|
|
574
|
+
|
|
575
|
+
// The pending-nav signal: a navigation flips it true on start and false on
|
|
576
|
+
// settle (success, supersession, or failure). Counted, not a bare boolean, so a
|
|
577
|
+
// nav that starts while another is still settling never clears the flag early —
|
|
578
|
+
// it reads false only when NO navigation is in flight. Wired to the optional
|
|
579
|
+
// change callback so the imperative and observable forms share one source.
|
|
580
|
+
const isNavigating = createBooleanSignal();
|
|
581
|
+
let pendingCount = 0;
|
|
582
|
+
|
|
583
|
+
if (options.onNavigatingChange !== undefined) {
|
|
584
|
+
isNavigating.subscribe(options.onNavigatingChange);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const navStarted = (): void => {
|
|
588
|
+
pendingCount += 1;
|
|
589
|
+
|
|
590
|
+
isNavigating.set(true);
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const navSettled = (): void => {
|
|
594
|
+
pendingCount -= 1;
|
|
595
|
+
|
|
596
|
+
if (pendingCount === 0) isNavigating.set(false);
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
// Warmed prefetches, keyed by the destination URL: a `<Link prefetch>` fires its
|
|
600
|
+
// fetch ahead of the click and parks the in-flight promise here, so the eventual
|
|
601
|
+
// navigation consumes it instead of starting a second round-trip.
|
|
602
|
+
const prefetches = new Map<string, PrefetchEntry>();
|
|
603
|
+
|
|
604
|
+
// Perform the fetch → swap → re-hydrate → scroll for one navigation. `kind`
|
|
605
|
+
// distinguishes a forward push (record where we came from, scroll to top) from a
|
|
606
|
+
// Back/Forward pop (restore the saved scroll, do not push a new entry).
|
|
607
|
+
const navigate = async (
|
|
608
|
+
url: string,
|
|
609
|
+
kind: SoftNavKind,
|
|
610
|
+
restore?: ScrollPosition,
|
|
611
|
+
): Promise<void> => {
|
|
612
|
+
const mine = ++currentToken;
|
|
613
|
+
inFlight?.abort();
|
|
614
|
+
|
|
615
|
+
// Announce the start (pending UI cue) and flip the in-flight flag — both fire
|
|
616
|
+
// once per navigation, even one a newer click later supersedes, so a started-
|
|
617
|
+
// but-aborted nav is observable too. `navSettled()` in `finally` always pairs.
|
|
618
|
+
options.onNavigateStart?.({ kind, url });
|
|
619
|
+
navStarted();
|
|
620
|
+
|
|
621
|
+
// Consume a warmed prefetch for this exact URL if one is in flight — its fetch
|
|
622
|
+
// already started (or finished) ahead of the click, so the navigation is
|
|
623
|
+
// instant. A prefetch is single-use: take it out of the cache so a later nav to
|
|
624
|
+
// the same URL re-fetches fresh rather than replaying a stale warmed body.
|
|
625
|
+
const warmed = prefetches.get(url);
|
|
626
|
+
prefetches.delete(url);
|
|
627
|
+
|
|
628
|
+
const controller = warmed?.controller ?? new AbortController();
|
|
629
|
+
inFlight = controller;
|
|
630
|
+
|
|
631
|
+
try {
|
|
632
|
+
const { html, url: landed } = await (warmed?.promise ?? fetchPage(url, controller.signal));
|
|
633
|
+
|
|
634
|
+
// A newer click/pop superseded us while the fetch was in flight — drop this
|
|
635
|
+
// stale result so it cannot clobber the newer navigation's swap or history.
|
|
636
|
+
if (mine !== currentToken) return;
|
|
637
|
+
|
|
638
|
+
// The same-origin gate again, now on the LANDED url: a same-origin link that
|
|
639
|
+
// 302-redirected cross-origin must NOT have its foreign body swapped into our
|
|
640
|
+
// live DOM — fall back to a real navigation, the link's own floor.
|
|
641
|
+
if (new URL(landed).origin !== origin) {
|
|
642
|
+
doc.location.assign(landed);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const title = swap(html, doc);
|
|
647
|
+
|
|
648
|
+
if (title !== undefined) doc.title = title;
|
|
649
|
+
|
|
650
|
+
// A forward nav records the new entry (carrying its eventual scroll, seeded
|
|
651
|
+
// at top); a pop is replaying an existing entry, so history is left alone.
|
|
652
|
+
if (kind === "push") {
|
|
653
|
+
hist.pushState({ lestoSoftNav: true, scroll: { x: 0, y: 0 } }, "", landed);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Re-hydrate against the JUST-SWAPPED document — `root` is the seam islands
|
|
657
|
+
// are looked up under, so the new body's mount scripts are the ones found,
|
|
658
|
+
// not the ambient global `document`'s.
|
|
659
|
+
//
|
|
660
|
+
// ACTIVATION CAVEAT for the layout-preserving partial swap: this re-hydrates
|
|
661
|
+
// the WHOLE document, which is correct ONLY for the full-body swap (today's
|
|
662
|
+
// path — no server emits `data-lesto-layout`, so `deepestSharedLayout` always
|
|
663
|
+
// returns undefined). The moment a server DOES emit those markers and a partial
|
|
664
|
+
// swap keeps an outer layout's DOM, re-scanning the whole doc here would re-mount
|
|
665
|
+
// that preserved layout's islands (no idempotency guard in `hydrateDocumentIslands`)
|
|
666
|
+
// and DESTROY the very state the partial swap preserves. So before wiring the
|
|
667
|
+
// marker, this must scope to the swapped subtree (the `defaultSwap` would have to
|
|
668
|
+
// surface which element it replaced) OR `hydrateDocumentIslands` must skip an
|
|
669
|
+
// already-mounted shell. Until then the partial-swap branch stays a dormant, pure
|
|
670
|
+
// fallback (see docs/plans/dx-parity.md W8) — never activate one without the other.
|
|
671
|
+
const hydration = rehydrate(registry, { root: doc });
|
|
672
|
+
|
|
673
|
+
// The swapped-in page brings its own `viewport`-prefetch links; register them
|
|
674
|
+
// so they warm as the user scrolls the new page. (Hover links are caught by
|
|
675
|
+
// the persistent delegated listeners, so they need no re-registration.)
|
|
676
|
+
registerViewportLinks();
|
|
677
|
+
|
|
678
|
+
// Restore the saved scroll for a Back/Forward, else go to the top of the new
|
|
679
|
+
// page — the browser's own behavior for a fresh navigation.
|
|
680
|
+
if (restore !== undefined) {
|
|
681
|
+
win.scrollTo(restore.x, restore.y);
|
|
682
|
+
} else {
|
|
683
|
+
win.scrollTo(0, 0);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Match a real navigation's a11y: announce the new title through a polite
|
|
687
|
+
// live region so screen readers hear the change, and move focus to the new
|
|
688
|
+
// page's top landmark so the next Tab / AT cursor starts there, not on a now
|
|
689
|
+
// detached node left over from the old body.
|
|
690
|
+
announceRoute(doc.title, doc);
|
|
691
|
+
focusMain(doc);
|
|
692
|
+
|
|
693
|
+
options.onNavigate?.({ kind, url: landed, hydration });
|
|
694
|
+
} catch (error) {
|
|
695
|
+
// A fetch aborted by a newer navigation is expected, not a failure — that
|
|
696
|
+
// newer navigation owns the page now, so swallow it rather than fall back to
|
|
697
|
+
// a real reload that would fight it.
|
|
698
|
+
if (mine !== currentToken) return;
|
|
699
|
+
|
|
700
|
+
onError(error, url);
|
|
701
|
+
} finally {
|
|
702
|
+
// Always pair the `navStarted()` above — the flag clears on success, on
|
|
703
|
+
// supersession (the early `return`s run `finally`), and on failure alike, so
|
|
704
|
+
// pending UI never sticks on after a navigation settles.
|
|
705
|
+
navSettled();
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
// Is this RESOLVED href (an anchor's `.href`, always absolute) one soft nav could
|
|
710
|
+
// navigate to — same-origin, not the current page? The prefetch gate: warming a
|
|
711
|
+
// cross-origin URL (or the page we're already on) is wasted work, and a
|
|
712
|
+
// cross-origin warm would be a needless request to a foreign server. Mirrors the
|
|
713
|
+
// click-time eligibility, minus the event flags (a prefetch is not a click). No
|
|
714
|
+
// try/catch: the caller only ever passes `anchor.href`, which the DOM has already
|
|
715
|
+
// normalized to a parseable absolute URL — so `new URL` here cannot throw.
|
|
716
|
+
const isPrefetchable = (href: string): boolean => {
|
|
717
|
+
const there = new URL(href);
|
|
718
|
+
|
|
719
|
+
if (there.origin !== origin) return false;
|
|
720
|
+
|
|
721
|
+
const here = new URL(doc.URL);
|
|
722
|
+
|
|
723
|
+
return there.pathname !== here.pathname || there.search !== here.search;
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
// Warm the fetch for a destination: start it (if not already warmed) and park the
|
|
727
|
+
// in-flight promise so the eventual `navigate(url)` consumes it. The promise's
|
|
728
|
+
// rejection is swallowed here — a prefetch that fails (offline, aborted on
|
|
729
|
+
// teardown) must never surface as an unhandled rejection; the click path re-tries
|
|
730
|
+
// and routes a real failure to `onError`. `url` is the RESOLVED absolute href,
|
|
731
|
+
// the same key `navigate` looks up.
|
|
732
|
+
const warmPrefetch = (url: string): void => {
|
|
733
|
+
if (prefetches.has(url) || !isPrefetchable(url)) return;
|
|
734
|
+
|
|
735
|
+
const controller = new AbortController();
|
|
736
|
+
const promise = fetchPage(url, controller.signal);
|
|
737
|
+
|
|
738
|
+
// Detach a no-op catch so a prefetch rejection is never unhandled; the entry's
|
|
739
|
+
// own `promise` (the un-caught one) is what `navigate` awaits, so the real
|
|
740
|
+
// error still reaches the click path.
|
|
741
|
+
promise.catch(() => {});
|
|
742
|
+
|
|
743
|
+
prefetches.set(url, { promise, controller });
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
// Before leaving the current entry, stamp its live scroll position into its
|
|
747
|
+
// history state, so a later Back/Forward to it restores where the user was.
|
|
748
|
+
const recordScroll = (): void => {
|
|
749
|
+
const state = (hist.state ?? {}) as { lestoSoftNav?: boolean };
|
|
750
|
+
|
|
751
|
+
hist.replaceState(
|
|
752
|
+
{ ...state, lestoSoftNav: true, scroll: { x: win.scrollX, y: win.scrollY } },
|
|
753
|
+
"",
|
|
754
|
+
doc.URL,
|
|
755
|
+
);
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
const onClick = (event: Event): void => {
|
|
759
|
+
// Adapt the real DOM event into the DOM-free {@link SoftNavClick} the pure
|
|
760
|
+
// `eligibleAnchor` reads: the modifier/button flags ride straight off the
|
|
761
|
+
// `MouseEvent`, and `anchor()` walks from the clicked element up to the
|
|
762
|
+
// nearest `<a>`, reading the eligibility surface (href/target/download/opt-out)
|
|
763
|
+
// off it. A real browser event has no `anchor()` of its own — resolving it
|
|
764
|
+
// here is exactly the seam that keeps the eligibility rules testable with a
|
|
765
|
+
// plain object.
|
|
766
|
+
const mouse = event as MouseEvent;
|
|
767
|
+
|
|
768
|
+
const click: SoftNavClick = {
|
|
769
|
+
button: mouse.button,
|
|
770
|
+
metaKey: mouse.metaKey,
|
|
771
|
+
ctrlKey: mouse.ctrlKey,
|
|
772
|
+
shiftKey: mouse.shiftKey,
|
|
773
|
+
altKey: mouse.altKey,
|
|
774
|
+
defaultPrevented: mouse.defaultPrevented,
|
|
775
|
+
anchor: () => resolveAnchor(mouse.target),
|
|
776
|
+
preventDefault: () => mouse.preventDefault(),
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
const anchor = eligibleAnchor(click);
|
|
780
|
+
|
|
781
|
+
if (anchor === undefined) return;
|
|
782
|
+
|
|
783
|
+
// Cross-origin links are real navigations — the one eligibility rule that
|
|
784
|
+
// needs the page origin, so it lives here, not in the pure `eligibleAnchor`.
|
|
785
|
+
if (new URL(anchor.href).origin !== origin) return;
|
|
786
|
+
|
|
787
|
+
// A same-document link (one that differs only by `#hash`, or is identical to
|
|
788
|
+
// the current URL) is the browser's to handle: hijacking it would refetch the
|
|
789
|
+
// page, swap the body, scroll to top, and churn history — destroying the native
|
|
790
|
+
// in-page anchor jump. So if the destination's pathname + search match the live
|
|
791
|
+
// URL, let it fall through untouched (no preventDefault, no navigate).
|
|
792
|
+
const here = new URL(doc.URL);
|
|
793
|
+
const there = new URL(anchor.href);
|
|
794
|
+
if (there.pathname === here.pathname && there.search === here.search) return;
|
|
795
|
+
|
|
796
|
+
// We are taking over: stop the browser's full navigation, remember this
|
|
797
|
+
// entry's scroll, and swap to the destination.
|
|
798
|
+
click.preventDefault();
|
|
799
|
+
|
|
800
|
+
recordScroll();
|
|
801
|
+
|
|
802
|
+
void navigate(anchor.href, "push");
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
const onPopState = (event: Event): void => {
|
|
806
|
+
const state = (event as unknown as { state: unknown }).state as {
|
|
807
|
+
lestoSoftNav?: boolean;
|
|
808
|
+
scroll?: ScrollPosition;
|
|
809
|
+
} | null;
|
|
810
|
+
|
|
811
|
+
// A pop to an entry we did NOT create (a real prior document, or the initial
|
|
812
|
+
// load's entry) is the browser's to handle — left alone, it does its native
|
|
813
|
+
// thing (including a bfcache restore that fires `pageshow`). We only replay our
|
|
814
|
+
// own soft-nav entries.
|
|
815
|
+
if (state === null || state.lestoSoftNav !== true) return;
|
|
816
|
+
|
|
817
|
+
void navigate(doc.URL, "pop", state.scroll ?? { x: 0, y: 0 });
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
// --- Prefetch wiring (opt-in per link via `data-lesto-prefetch`) ----------
|
|
821
|
+
|
|
822
|
+
// A pointer-enter / focus on (or within) a `hover`-strategy link warms its fetch.
|
|
823
|
+
// Delegated on the document (one listener, survives swaps) — `pointerover` and
|
|
824
|
+
// `focusin` both bubble, so a single pair covers mouse and keyboard intent.
|
|
825
|
+
const onPrefetchIntent = (event: Event): void => {
|
|
826
|
+
const found = prefetchTargetOf(event.target);
|
|
827
|
+
|
|
828
|
+
if (found?.strategy === "hover") warmPrefetch(found.anchor.href);
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
// The viewport-prefetch observer: an `IntersectionObserver` that warms a
|
|
832
|
+
// `viewport`-strategy link the moment it scrolls into view, then stops watching it
|
|
833
|
+
// (a single warm per link). Created lazily on the first viewport link so an app
|
|
834
|
+
// that uses only hover/no prefetch pays for no observer.
|
|
835
|
+
let viewportObserver: IntersectionObserverLike | undefined;
|
|
836
|
+
|
|
837
|
+
const observeViewportLink = (anchor: HTMLAnchorElement): void => {
|
|
838
|
+
if (viewportObserver === undefined) {
|
|
839
|
+
const factory = options.intersectionObserver;
|
|
840
|
+
|
|
841
|
+
// No factory and no real `IntersectionObserver` (jsdom, an old engine) → a
|
|
842
|
+
// coded refusal: viewport prefetch cannot run here. Hover + click still work;
|
|
843
|
+
// the caller branches on the code to (e.g.) downgrade to hover.
|
|
844
|
+
if (factory === undefined && typeof IntersectionObserver === "undefined") {
|
|
845
|
+
throw new UiError(
|
|
846
|
+
"UI_SOFTNAV_PREFETCH_UNSUPPORTED",
|
|
847
|
+
'Viewport prefetch needs an IntersectionObserver; this environment has none and none was injected. Use prefetch="hover" or pass options.intersectionObserver.',
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const make: IntersectionObserverFactory =
|
|
852
|
+
factory ?? ((cb) => new IntersectionObserver(cb) as IntersectionObserverLike);
|
|
853
|
+
|
|
854
|
+
viewportObserver = make((entries) => {
|
|
855
|
+
for (const entry of entries) {
|
|
856
|
+
if (!entry.isIntersecting) continue;
|
|
857
|
+
|
|
858
|
+
const link = entry.target;
|
|
859
|
+
|
|
860
|
+
viewportObserver?.unobserve(link);
|
|
861
|
+
|
|
862
|
+
if (link instanceof HTMLAnchorElement) warmPrefetch(link.href);
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
viewportObserver.observe(anchor);
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
// Register every current `viewport`-strategy link with the observer. Run on enable
|
|
871
|
+
// and after each swap (a swapped-in page brings its own prefetch links), so the
|
|
872
|
+
// set stays current without a per-link mutation observer. Hover links need no
|
|
873
|
+
// registration — they are caught by the delegated intent listeners.
|
|
874
|
+
const registerViewportLinks = (): void => {
|
|
875
|
+
// A `<body>`-less seam (a bare fake document used only to test history/scroll
|
|
876
|
+
// teardown, never a real page) has no links to scan — skip rather than throw,
|
|
877
|
+
// so the prefetch scan never makes the body a hard requirement of enabling.
|
|
878
|
+
const body = doc.body as Element | null | undefined;
|
|
879
|
+
|
|
880
|
+
if (body === null || body === undefined) return;
|
|
881
|
+
|
|
882
|
+
const links = body.querySelectorAll<HTMLAnchorElement>(`a[href][${PREFETCH_ATTR}="viewport"]`);
|
|
883
|
+
|
|
884
|
+
for (const anchor of Array.from(links)) observeViewportLink(anchor);
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
// Back/Forward listens on the window (the document's `defaultView`); a test
|
|
888
|
+
// injects a fake target so a synthetic `popstate` needs no real history move.
|
|
889
|
+
const popTarget: PopStateTarget = options.popStateTarget ?? (doc.defaultView as PopStateTarget);
|
|
890
|
+
|
|
891
|
+
// Register the initial viewport-prefetch links FIRST: the only throwing path on
|
|
892
|
+
// enable is the "viewport prefetch unsupported" refusal, and doing it before the
|
|
893
|
+
// listeners are attached means that refusal leaves NOTHING wired up — no leaked
|
|
894
|
+
// click/prefetch/popstate listener to undo without a `disable` handle.
|
|
895
|
+
registerViewportLinks();
|
|
896
|
+
|
|
897
|
+
doc.addEventListener("click", onClick);
|
|
898
|
+
doc.addEventListener("pointerover", onPrefetchIntent);
|
|
899
|
+
doc.addEventListener("focusin", onPrefetchIntent);
|
|
900
|
+
popTarget.addEventListener("popstate", onPopState);
|
|
901
|
+
|
|
902
|
+
// Seed the initial entry as a soft-nav entry carrying its scroll, so the FIRST
|
|
903
|
+
// Back to it restores correctly rather than falling through to the browser.
|
|
904
|
+
hist.replaceState(
|
|
905
|
+
{ lestoSoftNav: true, scroll: { x: win.scrollX, y: win.scrollY } },
|
|
906
|
+
"",
|
|
907
|
+
doc.URL,
|
|
908
|
+
);
|
|
909
|
+
|
|
910
|
+
const disable: DisableSoftNav = Object.assign(
|
|
911
|
+
(): void => {
|
|
912
|
+
doc.removeEventListener("click", onClick);
|
|
913
|
+
doc.removeEventListener("pointerover", onPrefetchIntent);
|
|
914
|
+
doc.removeEventListener("focusin", onPrefetchIntent);
|
|
915
|
+
popTarget.removeEventListener("popstate", onPopState);
|
|
916
|
+
|
|
917
|
+
// Stop watching for viewport prefetches and cancel every still-warming fetch,
|
|
918
|
+
// so a teardown (a hot reload, a test) leaves no observer or in-flight request
|
|
919
|
+
// dangling.
|
|
920
|
+
viewportObserver?.disconnect();
|
|
921
|
+
|
|
922
|
+
for (const { controller } of prefetches.values()) controller.abort();
|
|
923
|
+
|
|
924
|
+
prefetches.clear();
|
|
925
|
+
|
|
926
|
+
// Hand scroll restoration back to whatever it was on entry, so we leave no
|
|
927
|
+
// trace (the field is required, so this is a clean unconditional restore).
|
|
928
|
+
hist.scrollRestoration = priorScrollRestoration;
|
|
929
|
+
},
|
|
930
|
+
{ isNavigating },
|
|
931
|
+
);
|
|
932
|
+
|
|
933
|
+
return disable;
|
|
934
|
+
}
|