@markless/router 0.5.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.
@@ -3,6 +3,7 @@ import type { ModulePreloadInput } from '@markless/web/render-to-string';
3
3
 
4
4
  export interface FragmentNavigationConfig {
5
5
  readonly entry: string;
6
+ readonly entryImports: readonly string[];
6
7
  readonly linkAttribute: string;
7
8
  readonly prefetchAttribute: string;
8
9
  readonly replaceAttribute: string;
@@ -11,9 +12,14 @@ export interface FragmentNavigationConfig {
11
12
  readonly retireProperty: string;
12
13
  readonly streamExecutorGlobal: string;
13
14
  readonly streamedArmSelector: string;
14
- readonly requestHeader: string;
15
+ readonly requestPrefix: string;
15
16
  readonly regionEndMarker: string;
16
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];
17
23
  // 'document' apps swap the whole body: their shell renders from the request URL.
18
24
  readonly documentLoads: boolean;
19
25
  // A mouse fetches after resting this long on a link; press, focus and touch fetch at once.
@@ -23,7 +29,7 @@ export interface FragmentNavigationConfig {
23
29
  readonly prefetchConnections: number;
24
30
  // Paths the server answers with endpoints, not pages: never fetched before a click or as a fragment.
25
31
  readonly endpointPrefixes: readonly string[];
26
- // At most this many visible-link fragments are fetched at idle on fast connections, and only when the page shows no more than twice as many; 0 keeps HTML to intent only.
32
+ // At most this many visible-link fragments are fetched at idle on fast connections, in document order; 0 keeps HTML to intent only.
27
33
  readonly idleFragments: number;
28
34
  readonly idleWithoutConnectionInfo: boolean;
29
35
  // false: nothing is fetched before a click.
@@ -64,9 +70,11 @@ export type FragmentNavigationState = {
64
70
  readonly prefetch: (url: URL) => void;
65
71
  readonly parse: (url: string, text: string) => FragmentFetched;
66
72
  readonly idleDownloads: boolean;
67
- // Schedules the idle tier: at idle, download the landing code and a few fragments of the page links in view.
73
+ // Schedules the idle tier: at idle, fetch a few page fragments of the links in view; their code waits for intent.
68
74
  readonly rescan: () => void;
69
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>>;
70
78
  };
71
79
 
72
80
  export type FragmentScroll =
@@ -167,12 +175,15 @@ function regionOf(state: FragmentNavigationState, doc: Document): Element | null
167
175
  (
168
176
  doc.querySelector(`script[type="${state.config.routeScriptType}"]`) ??
169
177
  doc.querySelector('script[data-markless-router-link-resumer]')
170
- )?.closest('[data-async-container]') ?? null
178
+ )?.closest(`[${state.config.containerAttributes[0]}]`) ?? null
171
179
  );
172
180
  }
173
181
 
174
182
  function retire(state: FragmentNavigationState, root: Element): void {
175
- for (const container of [root, ...root.querySelectorAll('[data-async-container]')]) {
183
+ for (const container of [
184
+ root,
185
+ ...root.querySelectorAll(`[${state.config.containerAttributes[0]}]`),
186
+ ]) {
176
187
  const retireContainer = (container as unknown as Record<string, unknown>)[
177
188
  state.config.retireProperty
178
189
  ];
@@ -191,13 +202,157 @@ function reportAsync(error: unknown): void {
191
202
  });
192
203
  }
193
204
 
194
- function execute(d: Document, script: HTMLScriptElement): void {
195
- const type = script.type;
196
- if (type && type !== 'module' && type !== 'text/javascript') return;
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 {
197
223
  const clone = d.createElement('script');
198
224
  for (const attribute of script.attributes) clone.setAttribute(attribute.name, attribute.value);
199
225
  clone.textContent = script.textContent;
200
- script.replaceWith(clone);
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
+ }
201
356
  }
202
357
 
203
358
  function loaded(w: Window, links: readonly HTMLLinkElement[]): Promise<unknown> {
@@ -235,9 +390,8 @@ async function mergeHead(d: Document, next: Document) {
235
390
  else d.head.prepend(node);
236
391
  if (node.localName === 'link' && (node as HTMLLinkElement).rel === 'stylesheet')
237
392
  stylesheets.push(node as HTMLLinkElement);
238
- // A kept inline script already ran on this document; only one the destination adds runs.
239
- if (node.localName === 'script' && !(node as HTMLScriptElement).src)
240
- scripts.push(node as HTMLScriptElement);
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);
241
395
  }
242
396
  cursor = node;
243
397
  }
@@ -266,12 +420,62 @@ function syncHtmlAttributes(d: Document, next: Document): void {
266
420
  serverHtmlAttributes.set(d, new Set(incoming.getAttributeNames()));
267
421
  }
268
422
 
423
+ // Set from the page's config at the start of every swap.
424
+ let containerAttribute = '';
425
+
269
426
  const isContainer = (node: Node): boolean =>
270
- node.nodeType === 1 && (node as Element).hasAttribute('data-async-container');
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
+ }
271
475
 
272
476
  // A document app's shell has no runtime, so it is morphed in place: unchanged chrome keeps its nodes.
273
477
  // Containers are always replaced whole: their DOM is the new page's resume payload's to locate.
274
- function morphChildren(d: Document, from: Element, to: Element, fresh: Element[]): void {
478
+ function morphChildren(d: Document, from: Node, to: Node, fresh: Element[]): void {
275
479
  let cursor = from.firstChild;
276
480
  const skipContainers = () => {
277
481
  while (cursor && isContainer(cursor)) {
@@ -409,7 +613,7 @@ async function streamAppends(
409
613
  }
410
614
  }
411
615
  flush(true);
412
- end.parentNode?.removeChild(end);
616
+ if ((end as Comment).data !== state.config.pageEndComment) end.parentNode?.removeChild(end);
413
617
  return signal.aborted ? undefined : all;
414
618
  }
415
619
 
@@ -422,6 +626,7 @@ export async function navigate(
422
626
  const state = (d as FragmentHost).__marklessFragmentNav;
423
627
  const w = d.defaultView;
424
628
  if (!state || !w) return;
629
+ containerAttribute = state.config.containerAttributes[0];
425
630
  const session = sessionFor(d);
426
631
  const id = ++session.navigation;
427
632
  session.streaming?.abort();
@@ -442,12 +647,24 @@ export async function navigate(
442
647
  const documentLoads = state.config.documentLoads;
443
648
  const incoming = documentLoads ? next.body : regionOf(state, next);
444
649
  const outgoing = documentLoads ? d.body : regionOf(state, d);
445
- const scope = documentLoads ? d.body : outgoing?.parentElement;
650
+ const scope = documentLoads && !isContainer(d.body) ? d.body : outgoing?.parentElement;
446
651
  if (!incoming || !outgoing || !scope) {
447
652
  fallback(fetched.url);
448
653
  return;
449
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);
450
666
  const head = await mergeHead(d, next);
667
+ await Promise.race([Promise.all(code), new Promise((resolve) => w.setTimeout(resolve, 3000))]);
451
668
  if (id !== session.navigation) return taken.abort();
452
669
  let restore = scroll;
453
670
  if (mode === 'traverse') {
@@ -486,30 +703,66 @@ export async function navigate(
486
703
  for (const element of d.querySelectorAll(state.config.streamedArmSelector)) element.remove();
487
704
  state.shown = pageKey(new URL(w.location.href));
488
705
  if (documentLoads) syncHtmlAttributes(d, next);
489
- const fresh: Element[] = [];
490
706
  let region: Element;
491
- if (documentLoads) {
707
+ let end: Comment;
708
+ let scripts: Element[];
709
+ if (documentLoads && !isContainer(d.body)) {
710
+ const fresh: Element[] = [];
492
711
  region = d.body;
493
712
  morphChildren(d, region, incoming, fresh);
713
+ scripts = fresh.flatMap(scriptsIn);
714
+ region.append((end = d.createComment('')));
494
715
  } else {
495
716
  region = d.adoptNode(incoming);
496
- outgoing.replaceWith(region);
497
- fresh.push(region);
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
+ }
498
754
  }
499
755
  // 'instant': a `scroll-behavior: smooth` page would otherwise animate every landing.
500
756
  for (const box of offsets)
501
757
  sameBox(scope, box)?.scrollTo({ top: box.top, left: box.left, behavior: 'instant' });
502
- const end = d.createComment('');
503
- if (documentLoads) region.append(end);
504
- else region.after(end);
505
758
  for (const element of head.stale) element.remove();
506
759
  if (next.title && d.title !== next.title) d.title = next.title;
507
- for (const script of head.scripts) execute(d, script);
508
- for (const element of fresh)
509
- for (const script of element.localName === 'script'
510
- ? [element]
511
- : element.querySelectorAll('script'))
512
- execute(d, script as HTMLScriptElement);
760
+ const ran = runScripts(
761
+ d,
762
+ [...head.scripts, ...(scripts as HTMLScriptElement[])],
763
+ ranFiles,
764
+ () => id === session.navigation,
765
+ );
513
766
  const active = d.activeElement as HTMLElement | null;
514
767
  if (active && active !== d.body && !region.contains(active)) active.blur?.();
515
768
  (region.querySelector('[autofocus]') as HTMLElement | null)?.focus();
@@ -540,4 +793,5 @@ export async function navigate(
540
793
  if (w.requestIdleCallback) w.requestIdleCallback(idle, { timeout: 2000 });
541
794
  else w.setTimeout(idle, 200);
542
795
  }, reportAsync);
796
+ await ran;
543
797
  }