@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/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
+ }