@markless/router 0.4.0 → 0.6.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.
@@ -0,0 +1,797 @@
1
+ // The swap half of fragment navigation; the inline link bridge (vite/fragment-navigation.ts) fetches, this module commits.
2
+ import type { ModulePreloadInput } from '@markless/web/render-to-string';
3
+
4
+ export interface FragmentNavigationConfig {
5
+ readonly entry: string;
6
+ readonly entryImports: readonly string[];
7
+ readonly linkAttribute: string;
8
+ readonly prefetchAttribute: string;
9
+ readonly replaceAttribute: string;
10
+ readonly scrollAttribute: string;
11
+ readonly routeScriptType: string;
12
+ readonly retireProperty: string;
13
+ readonly streamExecutorGlobal: string;
14
+ readonly streamedArmSelector: string;
15
+ readonly requestPrefix: string;
16
+ readonly regionEndMarker: string;
17
+ readonly appendMarker: string;
18
+ // Close and open the page's nodes when the document's own element is the container; what is outside is the document's.
19
+ readonly pageEndComment: string;
20
+ readonly pageStartComment: string;
21
+ // The container's own attributes, never carried from the leaving page's element to the next one.
22
+ readonly containerAttributes: readonly [container: string, paths: string];
23
+ // 'document' apps swap the whole body: their shell renders from the request URL.
24
+ readonly documentLoads: boolean;
25
+ // A mouse fetches after resting this long on a link; press, focus and touch fetch at once.
26
+ readonly hoverDwellMs: number;
27
+ readonly prefetchTtlMs: number;
28
+ // At most this many unused prefetches may still be streaming; starting another cancels the oldest.
29
+ readonly prefetchConnections: number;
30
+ // Paths the server answers with endpoints, not pages: never fetched before a click or as a fragment.
31
+ readonly endpointPrefixes: readonly string[];
32
+ // At most this many visible-link fragments are fetched at idle on fast connections, in document order; 0 keeps HTML to intent only.
33
+ readonly idleFragments: number;
34
+ readonly idleWithoutConnectionInfo: boolean;
35
+ // false: nothing is fetched before a click.
36
+ readonly prefetch: boolean;
37
+ // Every page route's path, dynamic segments as ':' and a catch-all as '**': plain anchors to these navigate by fragment too.
38
+ readonly pages: readonly string[];
39
+ }
40
+
41
+ export type FragmentRouteCode = Record<string, readonly ModulePreloadInput[]>;
42
+
43
+ export type FragmentFetched = {
44
+ readonly url: string;
45
+ readonly prefix: string;
46
+ readonly doc: Document;
47
+ readonly rest?: ReadableStreamDefaultReader<Uint8Array>;
48
+ readonly tail: string;
49
+ };
50
+
51
+ export type FragmentPrefetch = {
52
+ readonly at: number;
53
+ readonly result: Promise<FragmentFetched>;
54
+ // The response is still arriving, so it holds a connection.
55
+ open: boolean;
56
+ readonly abort: () => void;
57
+ };
58
+
59
+ // What the inline bootstrap leaves on the document for the swap module (entries/fragment-entry.ts).
60
+ export type FragmentNavigationState = {
61
+ readonly config: FragmentNavigationConfig;
62
+ readonly routes: Record<string, readonly ModulePreloadInput[]>;
63
+ readonly prefetches: Map<string, FragmentPrefetch>;
64
+ readonly visited: Map<string, string>;
65
+ shown: string;
66
+ readonly eligible: (anchor: Element | null | undefined, forClick?: boolean) => URL | undefined;
67
+ readonly preloadCode: (url: URL, low: boolean) => void;
68
+ // speculative: a prefetch (low priority, `Purpose: prefetch`), not a click.
69
+ readonly request: (url: URL, speculative: boolean) => FragmentPrefetch;
70
+ readonly prefetch: (url: URL) => void;
71
+ readonly parse: (url: string, text: string) => FragmentFetched;
72
+ readonly idleDownloads: boolean;
73
+ // Schedules the idle tier: at idle, fetch a few page fragments of the links in view; their code waits for intent.
74
+ readonly rescan: () => void;
75
+ readonly scrolled: Set<Element>;
76
+ // Code the intent started preloading, by absolute href, settling once each file has arrived.
77
+ readonly arriving: Map<string, Promise<unknown>>;
78
+ };
79
+
80
+ export type FragmentScroll =
81
+ | 'reset'
82
+ | 'manual'
83
+ | { readonly x: number; readonly y: number; readonly id?: string };
84
+
85
+ export type FragmentEntryModule = {
86
+ readonly navigate: (
87
+ d: Document,
88
+ url: URL,
89
+ mode: 'push' | 'replace' | 'traverse',
90
+ scroll: FragmentScroll,
91
+ ) => Promise<void>;
92
+ };
93
+
94
+ type FragmentHost = Document & { __marklessFragmentNav?: FragmentNavigationState };
95
+ type Session = {
96
+ navigation: number;
97
+ streaming?: AbortController;
98
+ // The history entry on screen, and the last scroll offset seen for every entry this tab visited.
99
+ current?: string;
100
+ readonly positions: Map<string, { readonly x: number; readonly y: number }>;
101
+ };
102
+
103
+ const sessions = new WeakMap<Document, Session>();
104
+ const parsedVisits = new WeakMap<FragmentNavigationState, Map<string, FragmentFetched>>();
105
+
106
+ function sessionFor(d: Document): Session {
107
+ let session = sessions.get(d);
108
+ if (session) return session;
109
+ const created: Session = { navigation: 0, positions: new Map() };
110
+ sessions.set(d, created);
111
+ const w = d.defaultView!;
112
+ w.addEventListener(
113
+ 'scroll',
114
+ () => {
115
+ if (created.current)
116
+ created.positions.set(created.current, { x: w.scrollX, y: w.scrollY });
117
+ },
118
+ { passive: true },
119
+ );
120
+ w.addEventListener('pagehide', () => {
121
+ if (created.current) saveScroll(w, created.current);
122
+ w.history.scrollRestoration = 'auto';
123
+ });
124
+ w.addEventListener('pageshow', (event) => {
125
+ if ((event as PageTransitionEvent).persisted && w.history.state?.__marklessFragment)
126
+ w.history.scrollRestoration = 'manual';
127
+ });
128
+ return created;
129
+ }
130
+
131
+ function saveScroll(w: Window, id: string): void {
132
+ w.history.replaceState(
133
+ { ...w.history.state, __marklessFragment: { id, x: w.scrollX, y: w.scrollY } },
134
+ '',
135
+ );
136
+ }
137
+
138
+ // The entry the document landed on carries no id until it is left; naming it saves a history write per navigation.
139
+ const LANDING_ENTRY = '@landing';
140
+
141
+ const entryId = () => `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
142
+
143
+ const pageKey = (url: URL) => url.pathname + url.search;
144
+
145
+ function take(
146
+ state: FragmentNavigationState,
147
+ url: URL,
148
+ traverse: boolean,
149
+ now: number,
150
+ ): FragmentPrefetch {
151
+ const key = pageKey(url);
152
+ const existing = state.prefetches.get(key);
153
+ state.prefetches.delete(key);
154
+ // Back and Forward restore the page as it was left, never waiting on a newer fetch of it.
155
+ const cached = traverse ? state.visited.get(key) : undefined;
156
+ if (cached !== undefined) {
157
+ existing?.abort();
158
+ const ready = parsedVisits.get(state)?.get(key);
159
+ parsedVisits.get(state)?.delete(key);
160
+ return {
161
+ at: now,
162
+ result: Promise.resolve(ready ?? state.parse(url.href, cached)),
163
+ open: false,
164
+ abort: () => {},
165
+ };
166
+ }
167
+ if (existing && now - existing.at < state.config.prefetchTtlMs) return existing;
168
+ existing?.abort();
169
+ state.preloadCode(url, false);
170
+ return state.request(url, false);
171
+ }
172
+
173
+ function regionOf(state: FragmentNavigationState, doc: Document): Element | null {
174
+ return (
175
+ (
176
+ doc.querySelector(`script[type="${state.config.routeScriptType}"]`) ??
177
+ doc.querySelector('script[data-markless-router-link-resumer]')
178
+ )?.closest(`[${state.config.containerAttributes[0]}]`) ?? null
179
+ );
180
+ }
181
+
182
+ function retire(state: FragmentNavigationState, root: Element): void {
183
+ for (const container of [
184
+ root,
185
+ ...root.querySelectorAll(`[${state.config.containerAttributes[0]}]`),
186
+ ]) {
187
+ const retireContainer = (container as unknown as Record<string, unknown>)[
188
+ state.config.retireProperty
189
+ ];
190
+ if (typeof retireContainer !== 'function') continue;
191
+ try {
192
+ retireContainer();
193
+ } catch (error) {
194
+ reportAsync(error);
195
+ }
196
+ }
197
+ }
198
+
199
+ function reportAsync(error: unknown): void {
200
+ setTimeout(() => {
201
+ throw error;
202
+ });
203
+ }
204
+
205
+ // The JavaScript MIME types of the HTML spec, which a browser runs as classic scripts.
206
+ const JAVASCRIPT_TYPE =
207
+ /^(?:|(?:text|application)\/(?:x-)?(?:java|ecma)script|text\/(?:javascript1\.[0-5]|jscript|livescript))$/;
208
+
209
+ function scriptKind(script: HTMLScriptElement): 'classic' | 'module' | undefined {
210
+ const type = script.getAttribute('type');
211
+ const language = script.getAttribute('language');
212
+ const essence = (type ?? (language ? `text/${language}` : ''))
213
+ .split(';')[0]!
214
+ .trim()
215
+ .toLowerCase();
216
+ if (essence === 'module') return 'module';
217
+ if (!JAVASCRIPT_TYPE.test(essence) || script.hasAttribute('nomodule')) return undefined;
218
+ return 'classic';
219
+ }
220
+
221
+ // A parsed or adopted script never runs; a fresh element with its attributes and text does.
222
+ function runnable(d: Document, script: HTMLScriptElement): HTMLScriptElement {
223
+ const clone = d.createElement('script');
224
+ for (const attribute of script.attributes) clone.setAttribute(attribute.name, attribute.value);
225
+ clone.textContent = script.textContent;
226
+ return clone;
227
+ }
228
+
229
+ const settles = (script: HTMLScriptElement) =>
230
+ new Promise<unknown>((done) => {
231
+ script.addEventListener('load', done, { once: true });
232
+ script.addEventListener('error', done, { once: true });
233
+ });
234
+
235
+ // External files this document already ran: a page that includes one again keeps the loaded copy.
236
+ const ranFiles = new WeakMap<Document, Set<string>>();
237
+
238
+ function filesRan(d: Document): Set<string> {
239
+ let files = ranFiles.get(d);
240
+ if (!files) ranFiles.set(d, (files = new Set()));
241
+ for (const script of d.scripts) if (script.src && scriptKind(script)) files.add(script.src);
242
+ return files;
243
+ }
244
+
245
+ type LoadListener = {
246
+ readonly target: EventTarget;
247
+ readonly type: string;
248
+ readonly listener: EventListenerOrEventListenerObject;
249
+ };
250
+
251
+ // The document fired DOMContentLoaded and load already: listeners the page's scripts add are called once those scripts ran.
252
+ function captureLoadListeners(d: Document, w: Window, into: LoadListener[]): () => void {
253
+ const targets: Array<readonly [EventTarget, string]> = [];
254
+ if (d.readyState !== 'loading') targets.push([d, 'DOMContentLoaded']);
255
+ if (d.readyState === 'complete') targets.push([w, 'load']);
256
+ const restores = targets.map(([target, type]) => {
257
+ const own = Object.getOwnPropertyDescriptor(target, 'addEventListener');
258
+ const add = target.addEventListener;
259
+ target.addEventListener = function (
260
+ this: EventTarget,
261
+ kind: string,
262
+ listener: EventListenerOrEventListenerObject | null,
263
+ options?: boolean | AddEventListenerOptions,
264
+ ) {
265
+ if (kind === type && listener && this === target) into.push({ target, type, listener });
266
+ return add.call(this, kind, listener, options);
267
+ };
268
+ return () => {
269
+ if (own) Object.defineProperty(target, 'addEventListener', own);
270
+ else delete (target as { addEventListener?: unknown }).addEventListener;
271
+ };
272
+ });
273
+ return () => {
274
+ for (const restore of restores) restore();
275
+ };
276
+ }
277
+
278
+ function callLoadListeners(listeners: readonly LoadListener[], type: string): void {
279
+ const event = new Event(type);
280
+ for (const entry of listeners) {
281
+ if (entry.type !== type) continue;
282
+ try {
283
+ if (typeof entry.listener === 'function') entry.listener.call(entry.target, event);
284
+ else entry.listener.handleEvent(event);
285
+ } catch (error) {
286
+ reportAsync(error);
287
+ }
288
+ }
289
+ }
290
+
291
+ // A landing's order: blocking classic scripts one by one, async as they arrive, then deferred and module scripts, then load listeners.
292
+ async function runScripts(
293
+ d: Document,
294
+ scripts: readonly HTMLScriptElement[],
295
+ ran: Set<string>,
296
+ current: () => boolean,
297
+ ): Promise<void> {
298
+ const w = d.defaultView!;
299
+ const listeners: LoadListener[] = [];
300
+ const release = captureLoadListeners(d, w, listeners);
301
+ try {
302
+ const deferred: HTMLScriptElement[] = [];
303
+ for (const script of scripts) {
304
+ const kind = scriptKind(script);
305
+ if (!kind || !script.isConnected) continue;
306
+ const src = script.hasAttribute('src') ? script.src : '';
307
+ if (src && ran.has(src)) continue;
308
+ if (src) ran.add(src);
309
+ const async = script.hasAttribute('async') && (src !== '' || kind === 'module');
310
+ if (!async && (kind === 'module' || (src && script.hasAttribute('defer')))) {
311
+ deferred.push(script);
312
+ continue;
313
+ }
314
+ const clone = runnable(d, script);
315
+ if (async || !src) {
316
+ script.replaceWith(clone);
317
+ continue;
318
+ }
319
+ clone.async = false;
320
+ const done = settles(clone);
321
+ script.replaceWith(clone);
322
+ await done;
323
+ if (!current()) return;
324
+ }
325
+ let last: Promise<unknown> | undefined;
326
+ let tail: HTMLScriptElement | undefined;
327
+ for (const script of deferred) {
328
+ if (!script.isConnected) continue;
329
+ const clone = runnable(d, script);
330
+ clone.async = false;
331
+ last = clone.src ? settles(clone) : undefined;
332
+ tail = clone;
333
+ script.replaceWith(clone);
334
+ }
335
+ // An inline module fires no load event, so a marker queued after the last one says when it ran.
336
+ if (tail && !last) {
337
+ const key = `__marklessScriptsRan${Math.random().toString(36).slice(2)}`;
338
+ const host = w as unknown as Record<string, unknown>;
339
+ last = new Promise((done) => (host[key] = done));
340
+ const marker = d.createElement('script');
341
+ marker.type = 'module';
342
+ marker.async = false;
343
+ if (tail.nonce) marker.nonce = tail.nonce;
344
+ marker.textContent = `window.${key}();`;
345
+ d.head.append(marker);
346
+ await last;
347
+ delete host[key];
348
+ marker.remove();
349
+ } else await last;
350
+ if (!current()) return;
351
+ callLoadListeners(listeners, 'DOMContentLoaded');
352
+ callLoadListeners(listeners, 'load');
353
+ } finally {
354
+ release();
355
+ }
356
+ }
357
+
358
+ function loaded(w: Window, links: readonly HTMLLinkElement[]): Promise<unknown> {
359
+ if (links.length === 0) return Promise.resolve();
360
+ return Promise.race([
361
+ Promise.all(
362
+ links.map(
363
+ (link) =>
364
+ new Promise((resolve) => {
365
+ link.addEventListener('load', resolve, { once: true });
366
+ link.addEventListener('error', resolve, { once: true });
367
+ }),
368
+ ),
369
+ ),
370
+ new Promise((resolve) => w.setTimeout(resolve, 3000)),
371
+ ]);
372
+ }
373
+
374
+ // Existing head nodes stay where they are (moving a stylesheet re-applies it); new ones go in the destination's order.
375
+ async function mergeHead(d: Document, next: Document) {
376
+ const current = new Map<string, Element[]>();
377
+ for (const element of d.head.children) {
378
+ const list = current.get(element.outerHTML);
379
+ if (list) list.push(element);
380
+ else current.set(element.outerHTML, [element]);
381
+ }
382
+ const stylesheets: HTMLLinkElement[] = [];
383
+ const scripts: HTMLScriptElement[] = [];
384
+ let cursor: Element | null = null;
385
+ for (const element of Array.from(next.head.children)) {
386
+ const reused = current.get(element.outerHTML)?.shift();
387
+ const node = reused ?? d.adoptNode(element);
388
+ if (!reused) {
389
+ if (cursor) cursor.after(node);
390
+ else d.head.prepend(node);
391
+ if (node.localName === 'link' && (node as HTMLLinkElement).rel === 'stylesheet')
392
+ stylesheets.push(node as HTMLLinkElement);
393
+ // A kept head script already ran on this document; only one the destination adds runs.
394
+ if (node.localName === 'script') scripts.push(node as HTMLScriptElement);
395
+ }
396
+ cursor = node;
397
+ }
398
+ const stale: Element[] = [];
399
+ for (const element of [...current.values()].flat()) {
400
+ const rel = element.localName === 'link' ? (element as HTMLLinkElement).rel : '';
401
+ if (rel === 'modulepreload' || rel === 'preload') continue;
402
+ if (rel === 'stylesheet' || element.localName === 'style') stale.push(element);
403
+ else element.remove();
404
+ }
405
+ await loaded(d.defaultView!, stylesheets);
406
+ return { stale, scripts };
407
+ }
408
+
409
+ // Only attributes a server document set are the server's to remove: scripts own the rest (a theme class).
410
+ const serverHtmlAttributes = new WeakMap<Document, Set<string>>();
411
+
412
+ function syncHtmlAttributes(d: Document, next: Document): void {
413
+ const root = d.documentElement;
414
+ const incoming = next.documentElement;
415
+ for (const name of serverHtmlAttributes.get(d) ?? [])
416
+ if (!incoming.hasAttribute(name)) root.removeAttribute(name);
417
+ for (const attribute of incoming.attributes)
418
+ if (root.getAttribute(attribute.name) !== attribute.value)
419
+ root.setAttribute(attribute.name, attribute.value);
420
+ serverHtmlAttributes.set(d, new Set(incoming.getAttributeNames()));
421
+ }
422
+
423
+ // Set from the page's config at the start of every swap.
424
+ let containerAttribute = '';
425
+
426
+ const isContainer = (node: Node): boolean =>
427
+ node.nodeType === 1 && (node as Element).hasAttribute(containerAttribute);
428
+
429
+ function pageEnd(host: Node, data: string): Comment | undefined {
430
+ for (let node = host.lastChild; node; node = node.previousSibling)
431
+ if (node.nodeType === 8 && (node as Comment).data === data) return node as Comment;
432
+ return undefined;
433
+ }
434
+
435
+ function pageStart(host: Node, data: string): Comment | undefined {
436
+ for (let node = host.firstChild; node; node = node.nextSibling)
437
+ if (node.nodeType === 8 && (node as Comment).data === data) return node as Comment;
438
+ return undefined;
439
+ }
440
+
441
+ function nodesBefore(d: Document, node: Node): DocumentFragment {
442
+ const nodes = d.createDocumentFragment();
443
+ while (node.previousSibling) nodes.prepend(node.previousSibling);
444
+ return nodes;
445
+ }
446
+
447
+ function nodesAfter(d: Document, node: Node): DocumentFragment {
448
+ const nodes = d.createDocumentFragment();
449
+ while (node.nextSibling) nodes.append(node.nextSibling);
450
+ return nodes;
451
+ }
452
+
453
+ const scriptsIn = (element: Element): Element[] =>
454
+ element.localName === 'script' ? [element] : [...element.querySelectorAll('script')];
455
+
456
+ const serverHostAttributes = new WeakMap<Document, Set<string>>();
457
+
458
+ // The incoming container is a fresh element; attributes a script set on the outgoing one stay.
459
+ function carryHostAttributes(
460
+ d: Document,
461
+ from: Element,
462
+ to: Element,
463
+ own: readonly string[],
464
+ ): void {
465
+ const server = serverHostAttributes.get(d);
466
+ serverHostAttributes.set(d, new Set(to.getAttributeNames()));
467
+ for (const attribute of from.attributes)
468
+ if (
469
+ !to.hasAttribute(attribute.name) &&
470
+ !server?.has(attribute.name) &&
471
+ !own.includes(attribute.name)
472
+ )
473
+ to.setAttribute(attribute.name, attribute.value);
474
+ }
475
+
476
+ // A document app's shell has no runtime, so it is morphed in place: unchanged chrome keeps its nodes.
477
+ // Containers are always replaced whole: their DOM is the new page's resume payload's to locate.
478
+ function morphChildren(d: Document, from: Node, to: Node, fresh: Element[]): void {
479
+ let cursor = from.firstChild;
480
+ const skipContainers = () => {
481
+ while (cursor && isContainer(cursor)) {
482
+ const next: ChildNode | null = cursor.nextSibling;
483
+ cursor.remove();
484
+ cursor = next;
485
+ }
486
+ };
487
+ for (const incoming of Array.from(to.childNodes)) {
488
+ skipContainers();
489
+ if (
490
+ cursor &&
491
+ cursor.nodeType === incoming.nodeType &&
492
+ cursor.nodeName === incoming.nodeName &&
493
+ !isContainer(cursor) &&
494
+ !isContainer(incoming)
495
+ ) {
496
+ const next = cursor.nextSibling;
497
+ morphNode(d, cursor, incoming, fresh);
498
+ cursor = next;
499
+ continue;
500
+ }
501
+ const node = d.adoptNode(incoming);
502
+ from.insertBefore(node, cursor);
503
+ if (node.nodeType === 1) fresh.push(node as Element);
504
+ }
505
+ while (cursor) {
506
+ const next = cursor.nextSibling;
507
+ cursor.remove();
508
+ cursor = next;
509
+ }
510
+ }
511
+
512
+ function morphNode(d: Document, from: ChildNode, to: ChildNode, fresh: Element[]): void {
513
+ if (from.nodeType !== 1) {
514
+ if ((from as CharacterData).data !== (to as CharacterData).data)
515
+ (from as CharacterData).data = (to as CharacterData).data;
516
+ return;
517
+ }
518
+ if (from.isEqualNode(to)) return;
519
+ const element = from as Element;
520
+ const incoming = to as Element;
521
+ if (element.localName === 'script' || element.localName.includes('-')) {
522
+ const node = d.adoptNode(incoming);
523
+ element.replaceWith(node);
524
+ fresh.push(node);
525
+ return;
526
+ }
527
+ for (const name of element.getAttributeNames())
528
+ if (!incoming.hasAttribute(name)) element.removeAttribute(name);
529
+ for (const attribute of incoming.attributes)
530
+ if (element.getAttribute(attribute.name) !== attribute.value)
531
+ element.setAttribute(attribute.name, attribute.value);
532
+ morphChildren(d, element, incoming, fresh);
533
+ }
534
+
535
+ // Scrolled boxes (a sidebar) keep their offset when the same box is there after the swap:
536
+ // at its old place in the tree, or else by its id when the next page wraps it differently.
537
+ type ScrolledBox = {
538
+ readonly path: string | undefined;
539
+ readonly id: string;
540
+ readonly kind: string;
541
+ readonly top: number;
542
+ readonly left: number;
543
+ };
544
+
545
+ const boxKind = (element: Element) =>
546
+ `${element.localName}#${element.id}.${element.getAttribute('class') ?? ''}[${element.getAttribute('aria-label') ?? ''}]`;
547
+
548
+ function elementPath(root: Element, element: Element): string | undefined {
549
+ const steps: string[] = [];
550
+ for (let node: Element | null = element; node && node !== root; node = node.parentElement) {
551
+ const parent: Element | null = node.parentElement;
552
+ if (!parent) return undefined;
553
+ steps.push(`${[...parent.children].indexOf(node)}${node.localName}`);
554
+ }
555
+ return steps.reverse().join('/');
556
+ }
557
+
558
+ function elementAt(root: Element, path: string): Element | undefined {
559
+ let node: Element | undefined = root;
560
+ for (const step of path ? path.split('/') : []) {
561
+ const index = Number.parseInt(step, 10);
562
+ const child: Element | undefined = node?.children[index];
563
+ if (!child || `${index}${child.localName}` !== step) return undefined;
564
+ node = child;
565
+ }
566
+ return node;
567
+ }
568
+
569
+ function sameBox(root: Element, box: ScrolledBox): Element | undefined {
570
+ const placed = box.path === undefined ? undefined : elementAt(root, box.path);
571
+ if (placed) return placed;
572
+ const named = box.id ? root.ownerDocument.getElementById(box.id) : null;
573
+ return named && root.contains(named) && boxKind(named) === box.kind ? named : undefined;
574
+ }
575
+
576
+ // Streamed @pending arms arrive after the region, one delimited piece each, and run exactly as on a landing.
577
+ async function streamAppends(
578
+ d: Document,
579
+ state: FragmentNavigationState,
580
+ fetched: FragmentFetched,
581
+ end: Node,
582
+ signal: AbortSignal,
583
+ ): Promise<string | undefined> {
584
+ const marker = state.config.appendMarker;
585
+ const decoder = new TextDecoder();
586
+ let text = fetched.tail;
587
+ let all = fetched.tail;
588
+ const insert = (piece: string) => {
589
+ const parent = end.parentNode;
590
+ if (signal.aborted || !piece.trim() || !parent) return;
591
+ const range = d.createRange();
592
+ range.selectNodeContents(parent);
593
+ parent.insertBefore(range.createContextualFragment(piece), end);
594
+ };
595
+ const flush = (final: boolean) => {
596
+ const pieces = text.split(marker);
597
+ text = final ? '' : pieces.pop()!;
598
+ for (const piece of pieces) insert(piece);
599
+ };
600
+ if (fetched.rest) {
601
+ flush(false);
602
+ for (;;) {
603
+ const chunk = await fetched.rest.read();
604
+ if (signal.aborted) {
605
+ void fetched.rest.cancel().catch(() => {});
606
+ break;
607
+ }
608
+ if (chunk.done) break;
609
+ const decoded = decoder.decode(chunk.value, { stream: true });
610
+ text += decoded;
611
+ all += decoded;
612
+ flush(false);
613
+ }
614
+ }
615
+ flush(true);
616
+ if ((end as Comment).data !== state.config.pageEndComment) end.parentNode?.removeChild(end);
617
+ return signal.aborted ? undefined : all;
618
+ }
619
+
620
+ export async function navigate(
621
+ d: Document,
622
+ url: URL,
623
+ mode: 'push' | 'replace' | 'traverse',
624
+ scroll: FragmentScroll,
625
+ ): Promise<void> {
626
+ const state = (d as FragmentHost).__marklessFragmentNav;
627
+ const w = d.defaultView;
628
+ if (!state || !w) return;
629
+ containerAttribute = state.config.containerAttributes[0];
630
+ const session = sessionFor(d);
631
+ const id = ++session.navigation;
632
+ session.streaming?.abort();
633
+ const fallback = (href: string) => {
634
+ if (mode === 'traverse') w.location.reload();
635
+ else w.location.assign(href);
636
+ };
637
+ const taken = take(state, url, mode === 'traverse', w.performance.now());
638
+ let fetched: FragmentFetched;
639
+ try {
640
+ fetched = await taken.result;
641
+ } catch {
642
+ if (id === session.navigation) fallback(url.href);
643
+ return;
644
+ }
645
+ if (id !== session.navigation) return taken.abort();
646
+ const next = fetched.doc;
647
+ const documentLoads = state.config.documentLoads;
648
+ const incoming = documentLoads ? next.body : regionOf(state, next);
649
+ const outgoing = documentLoads ? d.body : regionOf(state, d);
650
+ const scope = documentLoads && !isContainer(d.body) ? d.body : outgoing?.parentElement;
651
+ if (!incoming || !outgoing || !scope) {
652
+ fallback(fetched.url);
653
+ return;
654
+ }
655
+ // The destination shows once its own code has arrived, so its first interaction never waits on a fetch.
656
+ const code = [...next.head.querySelectorAll<HTMLLinkElement>('link[rel="modulepreload"]')].map(
657
+ (link) => state.arriving.get(new URL(link.getAttribute('href') ?? '', d.baseURI).href),
658
+ );
659
+ for (const item of state.routes[url.pathname] ?? [])
660
+ code.push(
661
+ state.arriving.get(
662
+ new URL(typeof item === 'string' ? item : item.href, d.baseURI).href,
663
+ ),
664
+ );
665
+ const ranFiles = filesRan(d);
666
+ const head = await mergeHead(d, next);
667
+ await Promise.race([Promise.all(code), new Promise((resolve) => w.setTimeout(resolve, 3000))]);
668
+ if (id !== session.navigation) return taken.abort();
669
+ let restore = scroll;
670
+ if (mode === 'traverse') {
671
+ const key = (typeof scroll === 'object' ? scroll.id : undefined) ?? LANDING_ENTRY;
672
+ const saved = session.positions.get(key);
673
+ if (saved) restore = saved;
674
+ session.current = key;
675
+ } else {
676
+ const leaving =
677
+ session.current ??
678
+ (w.history.state as { __marklessFragment?: { id?: string } } | null)?.__marklessFragment
679
+ ?.id ??
680
+ LANDING_ENTRY;
681
+ session.positions.set(leaving, { x: w.scrollX, y: w.scrollY });
682
+ w.history.scrollRestoration = 'manual';
683
+ session.current = entryId();
684
+ const entry = { __marklessFragment: { id: session.current, x: 0, y: 0 } };
685
+ if (mode === 'push') w.history.pushState(entry, '', fetched.url);
686
+ else w.history.replaceState(entry, '', fetched.url);
687
+ }
688
+ const offsets: ScrolledBox[] = [];
689
+ for (const element of state.scrolled) {
690
+ if (!element.isConnected || !scope.contains(element)) continue;
691
+ if (element.scrollTop || element.scrollLeft)
692
+ offsets.push({
693
+ path: elementPath(scope, element),
694
+ id: element.id,
695
+ kind: boxKind(element),
696
+ top: element.scrollTop,
697
+ left: element.scrollLeft,
698
+ });
699
+ }
700
+ state.scrolled.clear();
701
+ retire(state, outgoing);
702
+ (w as unknown as Record<string, unknown>)[state.config.streamExecutorGlobal] = undefined;
703
+ for (const element of d.querySelectorAll(state.config.streamedArmSelector)) element.remove();
704
+ state.shown = pageKey(new URL(w.location.href));
705
+ if (documentLoads) syncHtmlAttributes(d, next);
706
+ let region: Element;
707
+ let end: Comment;
708
+ let scripts: Element[];
709
+ if (documentLoads && !isContainer(d.body)) {
710
+ const fresh: Element[] = [];
711
+ region = d.body;
712
+ morphChildren(d, region, incoming, fresh);
713
+ scripts = fresh.flatMap(scriptsIn);
714
+ region.append((end = d.createComment('')));
715
+ } else {
716
+ region = d.adoptNode(incoming);
717
+ const leavingEnd = pageEnd(outgoing, state.config.pageEndComment);
718
+ if (leavingEnd) {
719
+ carryHostAttributes(d, outgoing, region, state.config.containerAttributes);
720
+ end =
721
+ pageEnd(region, state.config.pageEndComment) ??
722
+ region.appendChild(d.createComment(state.config.pageEndComment));
723
+ // The document's own nodes after the page stay; a document app morphs them to its next shell.
724
+ const focused = d.activeElement as HTMLElement | null;
725
+ const kept = nodesAfter(d, leavingEnd);
726
+ const arriving = nodesAfter(d, end);
727
+ const fresh: Element[] = [];
728
+ if (documentLoads) morphChildren(d, kept, arriving, fresh);
729
+ const leavingStart = pageStart(outgoing, state.config.pageStartComment);
730
+ let start = pageStart(region, state.config.pageStartComment);
731
+ if (leavingStart || start) {
732
+ start ??= region.insertBefore(
733
+ d.createComment(state.config.pageStartComment),
734
+ region.firstChild,
735
+ );
736
+ const keptBefore = leavingStart
737
+ ? nodesBefore(d, leavingStart)
738
+ : d.createDocumentFragment();
739
+ const arrivingBefore = nodesBefore(d, start);
740
+ if (documentLoads) morphChildren(d, keptBefore, arrivingBefore, fresh);
741
+ region.insertBefore(keptBefore, start);
742
+ }
743
+ scripts = [...scriptsIn(region), ...fresh.flatMap(scriptsIn)];
744
+ region.append(kept);
745
+ outgoing.replaceWith(region);
746
+ // The document's own nodes moved to the new element; focus inside them stays put.
747
+ if (focused && focused !== d.activeElement && region.contains(focused))
748
+ focused.focus({ preventScroll: true });
749
+ } else {
750
+ scripts = scriptsIn(region);
751
+ outgoing.replaceWith(region);
752
+ region.after((end = d.createComment('')));
753
+ }
754
+ }
755
+ // 'instant': a `scroll-behavior: smooth` page would otherwise animate every landing.
756
+ for (const box of offsets)
757
+ sameBox(scope, box)?.scrollTo({ top: box.top, left: box.left, behavior: 'instant' });
758
+ for (const element of head.stale) element.remove();
759
+ if (next.title && d.title !== next.title) d.title = next.title;
760
+ const ran = runScripts(
761
+ d,
762
+ [...head.scripts, ...(scripts as HTMLScriptElement[])],
763
+ ranFiles,
764
+ () => id === session.navigation,
765
+ );
766
+ const active = d.activeElement as HTMLElement | null;
767
+ if (active && active !== d.body && !region.contains(active)) active.blur?.();
768
+ (region.querySelector('[autofocus]') as HTMLElement | null)?.focus();
769
+ if (restore === 'reset') {
770
+ const target = url.hash ? d.getElementById(decodeURIComponent(url.hash.slice(1))) : null;
771
+ if (target) target.scrollIntoView({ behavior: 'instant' });
772
+ else w.scrollTo({ top: 0, left: 0, behavior: 'instant' });
773
+ } else if (restore !== 'manual')
774
+ w.scrollTo({ top: restore.y, left: restore.x, behavior: 'instant' });
775
+ d.dispatchEvent(new CustomEvent('marklessrouterrendered'));
776
+ state.rescan();
777
+ const controller = new AbortController();
778
+ session.streaming = controller;
779
+ const key = state.shown;
780
+ void streamAppends(d, state, fetched, end, controller.signal).then((rest) => {
781
+ if (rest === undefined) return;
782
+ state.visited.delete(key);
783
+ const text = `${fetched.prefix}${state.config.regionEndMarker}${rest}`;
784
+ state.visited.set(key, text);
785
+ while (state.visited.size > 8) state.visited.delete(state.visited.keys().next().value!);
786
+ const parsed = parsedVisits.get(state) ?? new Map<string, FragmentFetched>();
787
+ parsedVisits.set(state, parsed);
788
+ // Parsing runs no page code; done at idle, a Back to this page only swaps.
789
+ const idle = () => {
790
+ if (state.visited.get(key) === text) parsed.set(key, state.parse(fetched.url, text));
791
+ for (const other of parsed.keys()) if (!state.visited.has(other)) parsed.delete(other);
792
+ };
793
+ if (w.requestIdleCallback) w.requestIdleCallback(idle, { timeout: 2000 });
794
+ else w.setTimeout(idle, 200);
795
+ }, reportAsync);
796
+ await ran;
797
+ }