@markless/router 0.8.0 → 0.9.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.
@@ -1,4 +1,4 @@
1
- import { a as ROUTE_SCRIPT_TYPE, c as servedHtmlAttributesScript, d as REPLACE_ATTRIBUTE, f as SCROLL_ATTRIBUTE, i as HTML_ATTRIBUTES_SLOT, l as LINK_ATTRIBUTE, n as requestLocals, o as SERVED_HTML_ATTRIBUTES_PROPERTY, r as takeMiddlewareHeaders, u as PREFETCH_ATTRIBUTE } from "./request-locals-BDAhNpEa.js";
1
+ import { a as ROUTE_SCRIPT_TYPE, d as PREFETCH_ATTRIBUTE, f as REPLACE_ATTRIBUTE, i as HTML_ATTRIBUTES_SLOT, l as servedHtmlAttributesScript, n as requestLocals, o as SERVED_BODY_ATTRIBUTES_PROPERTY, p as SCROLL_ATTRIBUTE, r as takeMiddlewareHeaders, s as SERVED_HTML_ATTRIBUTES_PROPERTY, u as LINK_ATTRIBUTE } from "./request-locals-BPfJaoM_.js";
2
2
  import { i as matchRouteManifest, n as buildRouteManifestFromFileIds, o as normalizeRouteFileId } from "./route-manifest-DmoxXanW.js";
3
3
  import { n as __marklessDebugBootstrapSource } from "./debug-channel-Di6SppZv.js";
4
4
  import { CONTAINER_ATTRIBUTE, PROTOCOL_CONTAINER_END_COMMENT, PROTOCOL_CONTAINER_START_COMMENT, PROTOCOL_LOCATOR_PATHS_ATTRIBUTE } from "@markless/web/fns/container-region";
@@ -252,13 +252,27 @@ function runFragmentNavigation(d, config, routes, importModule, scrolled, arrivi
252
252
  d.head.appendChild(link);
253
253
  }
254
254
  };
255
+ const parseAsLoaded = (html) => {
256
+ const raw = [];
257
+ const emptied = html.replace(/<!--[\s\S]*?-->|<(script|style|textarea|title)\b[^>]*>[\s\S]*?<\/\1\s*>|<noscript\b([^>]*)>([\s\S]*?)<\/noscript\s*>/gi, (match, _rawText, attributes, content) => {
258
+ if (content === void 0) return match;
259
+ raw.push(content);
260
+ return `<noscript${attributes ?? ""}></noscript>`;
261
+ });
262
+ if (!raw.length) return new DOMParser().parseFromString(html, "text/html");
263
+ const doc = new DOMParser().parseFromString(emptied, "text/html");
264
+ const tags = doc.getElementsByTagName("noscript");
265
+ if (tags.length !== raw.length) return new DOMParser().parseFromString(html, "text/html");
266
+ for (let index = 0; index < tags.length; index++) tags[index].textContent = raw[index];
267
+ return doc;
268
+ };
255
269
  const parse = (url, text) => {
256
270
  const at = text.indexOf(config.regionEndMarker);
257
271
  const prefix = at === -1 ? text : text.slice(0, at);
258
272
  return {
259
273
  url,
260
274
  prefix,
261
- doc: new DOMParser().parseFromString(prefix, "text/html"),
275
+ doc: parseAsLoaded(prefix),
262
276
  tail: at === -1 ? "" : text.slice(at + config.regionEndMarker.length)
263
277
  };
264
278
  };
@@ -876,7 +890,7 @@ function createServerEntry(options) {
876
890
  resumeModuleUrl: options.resumeEntryPath ?? baseArtifact?.resumeModuleUrl,
877
891
  modulePreloads: (html, resumes) => landingModulePreloads(resumes ? options.routeSsrModulePreloads?.[file] : void 0, linkBridges?.[html.includes("href=\"#/") ? 1 : 0])
878
892
  };
879
- const headStart = (documentLoads && fragmentNavigation && !fragment ? servedHtmlAttributesScript() : "") + (fragment ? "" : options.importMap ?? "");
893
+ const headStart = (fragmentNavigation && !fragment ? servedHtmlAttributesScript() : "") + (fragment ? "" : options.importMap ?? "");
880
894
  if (options.render === "blocking") {
881
895
  const pageHtml = splitPageHtml(await renderToString(routedArtifact, renderOptions));
882
896
  privateToVisitor();
@@ -1014,7 +1028,17 @@ function landingModulePreloads(routePreloads, linkBridgePath) {
1014
1028
  const preloads = [];
1015
1029
  addModulePreloads(preloads, /* @__PURE__ */ new Set(), routePreloads);
1016
1030
  if (linkBridgePath) preloads.push(linkBridgePath);
1017
- return preloads.length > 0 ? preloads : void 0;
1031
+ return preloads.length > 0 ? preloads.map(lowFetchPriority) : void 0;
1032
+ }
1033
+ function lowFetchPriority(preload) {
1034
+ if (typeof preload === "string") return {
1035
+ href: preload,
1036
+ fetchPriority: "low"
1037
+ };
1038
+ return preload.fetchPriority ? preload : {
1039
+ ...preload,
1040
+ fetchPriority: "low"
1041
+ };
1018
1042
  }
1019
1043
  function routerLinkHrefs(html) {
1020
1044
  return [...html.matchAll(/<a\b(?=[^>]*\bdata-markless-router-link(?:[\s=>]|$))(?=[^>]*\bhref="([^"]*)")[^>]*>/g)].map((match) => unescapeHtmlAttribute(match[1] ?? ""));
@@ -1290,6 +1314,7 @@ function fragmentConfig(fragment, documentLoads) {
1290
1314
  scrollAttribute: SCROLL_ATTRIBUTE,
1291
1315
  routeScriptType: ROUTE_SCRIPT_TYPE,
1292
1316
  servedHtmlAttributesProperty: SERVED_HTML_ATTRIBUTES_PROPERTY,
1317
+ servedBodyAttributesProperty: SERVED_BODY_ATTRIBUTES_PROPERTY,
1293
1318
  retireProperty: CONTAINER_RETIRE_PROPERTY,
1294
1319
  streamExecutorGlobal: STREAM_ARM_EXECUTOR_GLOBAL,
1295
1320
  streamedArmSelector: STREAMED_ARM_SELECTOR,
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { d as REPLACE_ATTRIBUTE, f as SCROLL_ATTRIBUTE, i as HTML_ATTRIBUTES_SLOT, l as LINK_ATTRIBUTE, s as routeMountTarget, t as rememberRequestLocals, u as PREFETCH_ATTRIBUTE } from "./request-locals-BDAhNpEa.js";
1
+ import { c as routeMountTarget, d as PREFETCH_ATTRIBUTE, f as REPLACE_ATTRIBUTE, i as HTML_ATTRIBUTES_SLOT, p as SCROLL_ATTRIBUTE, t as rememberRequestLocals, u as LINK_ATTRIBUTE } from "./request-locals-BPfJaoM_.js";
2
2
  import { a as normalizeRequestPathname, c as parseRequestFile, i as matchRouteManifest, l as transformRequestFileSource, n as buildRouteManifestFromFileIds, o as normalizeRouteFileId, s as normalizeRequestFileId } from "./route-manifest-DmoxXanW.js";
3
3
  import { createExternalDelegateStructureTokens, createExternalDelegateView } from "@markless/web/fns/external-delegate";
4
4
  import { render } from "@markless/web/render";
@@ -9,8 +9,9 @@ const PREFETCH_ATTRIBUTE = "data-markless-router-prefetch";
9
9
  const ROUTE_SCRIPT_TYPE = "@markless/core/route";
10
10
  const HTML_ATTRIBUTES_SLOT = "__marklessRouterHtmlAttributes";
11
11
  const SERVED_HTML_ATTRIBUTES_PROPERTY = "__marklessServedHtmlAttributes";
12
+ const SERVED_BODY_ATTRIBUTES_PROPERTY = "__marklessServedBodyAttributes";
12
13
  function servedHtmlAttributesScript() {
13
- return `<script>document.documentElement.${SERVED_HTML_ATTRIBUTES_PROPERTY}=document.documentElement.getAttributeNames()<\/script>`;
14
+ return `<script>(()=>{const r=document.documentElement,p=(e)=>Array.from(e.attributes,(a)=>[a.name,a.value]),b=()=>!!document.body&&!!(r.${SERVED_BODY_ATTRIBUTES_PROPERTY}=p(document.body));r.${SERVED_HTML_ATTRIBUTES_PROPERTY}=p(r);b()||new MutationObserver((_,o)=>{b()&&o.disconnect()}).observe(r,{childList:true})})()<\/script>`;
14
15
  }
15
16
  function routeMountTarget(document, currentRoot) {
16
17
  const root = currentRoot ?? document.querySelector(`script[type="@markless/core/route"]`)?.parentElement;
@@ -91,4 +92,4 @@ function takeMiddlewareHeaders(request) {
91
92
  return record.taken;
92
93
  }
93
94
  //#endregion
94
- export { ROUTE_SCRIPT_TYPE as a, servedHtmlAttributesScript as c, REPLACE_ATTRIBUTE as d, SCROLL_ATTRIBUTE as f, HTML_ATTRIBUTES_SLOT as i, LINK_ATTRIBUTE as l, requestLocals as n, SERVED_HTML_ATTRIBUTES_PROPERTY as o, takeMiddlewareHeaders as r, routeMountTarget as s, rememberRequestLocals as t, PREFETCH_ATTRIBUTE as u };
95
+ export { ROUTE_SCRIPT_TYPE as a, routeMountTarget as c, PREFETCH_ATTRIBUTE as d, REPLACE_ATTRIBUTE as f, HTML_ATTRIBUTES_SLOT as i, servedHtmlAttributesScript as l, requestLocals as n, SERVED_BODY_ATTRIBUTES_PROPERTY as o, SCROLL_ATTRIBUTE as p, takeMiddlewareHeaders as r, SERVED_HTML_ATTRIBUTES_PROPERTY as s, rememberRequestLocals as t, LINK_ATTRIBUTE as u };
@@ -1 +1 @@
1
- export { t as createServerEntry, n as routerLinkBridgeModules } from "../../create-server-entry-BsYTWdxi.js";
1
+ export { t as createServerEntry, n as routerLinkBridgeModules } from "../../create-server-entry-CVhSjR45.js";
@@ -93,6 +93,7 @@ type ProtocolStatePayload = {
93
93
  readonly key: unknown;
94
94
  readonly error: unknown;
95
95
  };
96
+ readonly hostScope?: string;
96
97
  }>;
97
98
  readonly sharedSeeds?: ReadonlyArray<{
98
99
  readonly graphNodeId: string;
@@ -866,7 +867,7 @@ type ResumeDomElement = ResumeDomNode & {
866
867
  readonly dispatchEvent?: (event: ResumeSharedPatchEvent) => boolean;
867
868
  readonly setAttribute?: (name: string, value: string) => void;
868
869
  readonly removeAttribute?: (name: string) => void;
869
- __marklessDelegatedDispatch?: boolean;
870
+ __marklessDelegatedDispatch?: boolean | string;
870
871
  __marklessListen?: (eventType: string) => void;
871
872
  __marklessDispatch?: (input: {
872
873
  readonly root: ResumeDomElement;
@@ -4,7 +4,7 @@ import { i as protocolStateVersion, n as protocolInstanceQualifies, t as protoco
4
4
  /** The field names each templated record list may spell, in the order its templates index them. */
5
5
  const PROTOCOL_RECORD_KEYS = {
6
6
  "markless/state": {
7
- computed: "graphNodeId name async deriveSymbolId value version root $type $ref source flags records id type fields items entries values bytes arrayType buffer byteOffset length byteLength directValue dependencies path snapshot status key error",
7
+ computed: "graphNodeId name async deriveSymbolId value version root $type $ref source flags records id type fields items entries values bytes arrayType buffer byteOffset length byteLength directValue dependencies path snapshot status key error hostScope",
8
8
  sharedSeeds: "graphNodeId deriveSymbolId dependencies path reads",
9
9
  sharedDefinitions: "id name exportedName scope version graphNodeIds projectionIds dependencies definitionId definitionName returnProperties kind graphNodeId path"
10
10
  },
package/dist/vite.js CHANGED
@@ -1,8 +1,8 @@
1
- import { d as REPLACE_ATTRIBUTE, f as SCROLL_ATTRIBUTE, l as LINK_ATTRIBUTE, u as PREFETCH_ATTRIBUTE } from "./request-locals-BDAhNpEa.js";
1
+ import { d as PREFETCH_ATTRIBUTE, f as REPLACE_ATTRIBUTE, p as SCROLL_ATTRIBUTE, u as LINK_ATTRIBUTE } from "./request-locals-BPfJaoM_.js";
2
2
  import { i as matchRouteManifest, l as transformRequestFileSource, n as buildRouteManifestFromFileIds, o as normalizeRouteFileId, r as isPageModuleFile, t as assertNoEndpointConflicts } from "./route-manifest-DmoxXanW.js";
3
3
  import { t as __marklessRouteHref } from "./route-href-CGooO_g6.js";
4
4
  import { r as protocolIslandSegment } from "./protocol-constants-B4WxrZAS.js";
5
- import { a as runFragmentNavigation, c as listenForLinkIntent, i as FRAGMENT_PATH_PREFIX, n as routerLinkBridgeModules, o as startFragmentNavigation, s as documentHandlersMessage } from "./create-server-entry-BsYTWdxi.js";
5
+ import { a as runFragmentNavigation, c as listenForLinkIntent, i as FRAGMENT_PATH_PREFIX, n as routerLinkBridgeModules, o as startFragmentNavigation, s as documentHandlersMessage } from "./create-server-entry-CVhSjR45.js";
6
6
  import { dirname, extname, isAbsolute, join, normalize, relative, resolve } from "pathe";
7
7
  import { decodePath, joinURL, parsePath, parseQuery, parseURL, stringifyQuery, withLeadingSlash, withQuery, withoutBase, withoutLeadingSlash } from "ufo";
8
8
  import { existsSync, readdirSync } from "node:fs";
@@ -1527,6 +1527,30 @@ function fragmentRequestModule() {
1527
1527
  };
1528
1528
  }
1529
1529
  //#endregion
1530
+ //#region src/vite/nitro-dev-close.ts
1531
+ function devNitroState() {
1532
+ return { nitro: void 0 };
1533
+ }
1534
+ function devNitroModule(state) {
1535
+ return {
1536
+ name: "markless-router:dev-nitro",
1537
+ setup(nitro) {
1538
+ state.nitro = nitro;
1539
+ }
1540
+ };
1541
+ }
1542
+ function devNitroClosePlugin(state) {
1543
+ return {
1544
+ name: "markless-router:dev-nitro-close",
1545
+ apply: "serve",
1546
+ async closeBundle() {
1547
+ const nitro = state.nitro;
1548
+ state.nitro = void 0;
1549
+ await nitro?.close();
1550
+ }
1551
+ };
1552
+ }
1553
+ //#endregion
1530
1554
  //#region src/vite/nitro-prerender.ts
1531
1555
  function serverBuildState() {
1532
1556
  return { entry: void 0 };
@@ -1621,8 +1645,10 @@ function router(options = {}) {
1621
1645
  const navigationEntry = resumeEntryState();
1622
1646
  const fragmentEntry = resumeEntryState();
1623
1647
  const routePreloads = routePreloadState();
1648
+ const serverBuild = serverBuildState();
1649
+ const devNitro = devNitroState();
1624
1650
  return [
1625
- routerConfigPlugin(nitroPlugins, resumeEntry, prerenderWakeEntry, navigationEntry, routePreloads, fragmentEntry, serverBuildState()),
1651
+ routerConfigPlugin(nitroPlugins, resumeEntry, prerenderWakeEntry, navigationEntry, routePreloads, fragmentEntry, serverBuild, devNitro),
1626
1652
  devSourceModuleRequestPlugin(),
1627
1653
  devPageForUrlPlugin(options),
1628
1654
  pageServerOnlyPropsPlugin(),
@@ -1633,6 +1659,7 @@ function router(options = {}) {
1633
1659
  htmlTransformPlugin(),
1634
1660
  virtualModulesPlugin(resumeEntry, prerenderWakeEntry, navigationEntry, routePreloads, options, fragmentEntry),
1635
1661
  linkBridgeFilesPlugin(navigationEntry, fragmentEntry, options),
1662
+ devNitroClosePlugin(devNitro),
1636
1663
  nitroPlugins
1637
1664
  ];
1638
1665
  }
@@ -1697,7 +1724,7 @@ function devSourceModuleRequestPlugin() {
1697
1724
  }
1698
1725
  };
1699
1726
  }
1700
- function routerConfigPlugin(nitroPluginsFromRouter, resumeEntry, prerenderWakeEntry, navigationEntry, routePreloads, fragmentEntry = resumeEntryState(), serverBuild = serverBuildState()) {
1727
+ function routerConfigPlugin(nitroPluginsFromRouter, resumeEntry, prerenderWakeEntry, navigationEntry, routePreloads, fragmentEntry = resumeEntryState(), serverBuild = serverBuildState(), devNitro = devNitroState()) {
1701
1728
  let clientOutDir = "";
1702
1729
  let clientEnvironmentName = "";
1703
1730
  let clientEnvironmentConfig;
@@ -1731,7 +1758,7 @@ function routerConfigPlugin(nitroPluginsFromRouter, resumeEntry, prerenderWakeEn
1731
1758
  ssrEnvironment.build.rolldownOptions.input ??= scopedVirtualEntryId(SERVER_ENTRY_ID, config.root);
1732
1759
  const serverWatch = withWatchIgnores(config.server?.watch);
1733
1760
  return {
1734
- nitro: createNitroConfig(config.nitro, config.root, serverWatch.ignored, env?.command === "build", serverBuild),
1761
+ nitro: createNitroConfig(config.nitro, config.root, serverWatch.ignored, env?.command === "build", serverBuild, devNitro),
1735
1762
  server: {
1736
1763
  ...config.server,
1737
1764
  watch: serverWatch
@@ -1927,7 +1954,7 @@ function requestFileBuildTransformPlugin(root) {
1927
1954
  }
1928
1955
  };
1929
1956
  }
1930
- function createNitroConfig(nitroConfig, root = ".", serverWatchIgnored = [], isBuild = false, serverBuild = serverBuildState()) {
1957
+ function createNitroConfig(nitroConfig, root = ".", serverWatchIgnored = [], isBuild = false, serverBuild = serverBuildState(), devNitro = devNitroState()) {
1931
1958
  const scanDirs = Array.isArray(nitroConfig?.scanDirs) ? nitroConfig.scanDirs.filter((dir) => typeof dir === "string") : [];
1932
1959
  const watchOptions = withWatchIgnores(nitroConfig?.watchOptions, serverWatchIgnored);
1933
1960
  const publicAssets = Array.isArray(nitroConfig?.publicAssets) ? nitroConfig.publicAssets : [];
@@ -1950,7 +1977,7 @@ function createNitroConfig(nitroConfig, root = ".", serverWatchIgnored = [], isB
1950
1977
  modules: [
1951
1978
  ...nitroConfig?.modules ?? [],
1952
1979
  fragmentRequestModule(),
1953
- ...isBuild ? [prerenderRendererModule(serverBuild)] : []
1980
+ ...isBuild ? [prerenderRendererModule(serverBuild)] : [devNitroModule(devNitro)]
1954
1981
  ],
1955
1982
  ...isBuild && { routeRules: {
1956
1983
  [`/${MARKLESS_BUILD_PREFIX}**`]: { headers: { "cache-control": "public, max-age=31536000, immutable" } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markless/router",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Markless router: client-side navigation with SSR streaming and build-time preload maps.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -64,12 +64,12 @@
64
64
  "pathe": "^2.0.3",
65
65
  "satteri": "0.6.3",
66
66
  "ufo": "^1.6.4",
67
- "@markless/bundler": "0.8.0",
68
- "@markless/web": "0.8.0",
69
- "@markless/compiler": "0.8.0"
67
+ "@markless/compiler": "0.9.0",
68
+ "@markless/bundler": "0.9.0",
69
+ "@markless/web": "0.9.0"
70
70
  },
71
71
  "devDependencies": {
72
- "@markless/analyzer": "0.8.0"
72
+ "@markless/analyzer": "0.9.0"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "typescript": "^5.9.3",
@@ -10,6 +10,7 @@ export interface FragmentNavigationConfig {
10
10
  readonly scrollAttribute: string;
11
11
  readonly routeScriptType: string;
12
12
  readonly servedHtmlAttributesProperty: string;
13
+ readonly servedBodyAttributesProperty: string;
13
14
  readonly retireProperty: string;
14
15
  readonly streamExecutorGlobal: string;
15
16
  readonly streamedArmSelector: string;
@@ -407,21 +408,126 @@ async function mergeHead(d: Document, next: Document) {
407
408
  return { stale, scripts };
408
409
  }
409
410
 
410
- // Only attributes a server document set are the server's to remove: scripts own the rest (a theme class).
411
- const serverHtmlAttributes = new WeakMap<Document, Set<string>>();
411
+ // A swap applies only what the server changed between its documents; a script's own changes (a theme class) stay.
412
+ type ServedAttributes = ReadonlyMap<string, string>;
413
+ const servedAttributes = {
414
+ html: new WeakMap<Document, ServedAttributes>(),
415
+ body: new WeakMap<Document, ServedAttributes>(),
416
+ };
417
+
418
+ const attributePairs = (element: Element): ServedAttributes =>
419
+ new Map(Array.from(element.attributes, (attribute) => [attribute.name, attribute.value]));
420
+
421
+ // What the served document gave <html> or <body>: the last swap's incoming, else the landing's own record.
422
+ function servedBefore(
423
+ d: Document,
424
+ which: 'html' | 'body',
425
+ servedProperty: string,
426
+ ): ServedAttributes {
427
+ return (
428
+ servedAttributes[which].get(d) ??
429
+ new Map(
430
+ (
431
+ d.documentElement as unknown as Record<
432
+ string,
433
+ readonly [string, string][] | undefined
434
+ >
435
+ )[servedProperty],
436
+ )
437
+ );
438
+ }
439
+
440
+ function applyServedChanges(
441
+ d: Document,
442
+ target: HTMLElement,
443
+ served: ServedAttributes,
444
+ incoming: ServedAttributes,
445
+ ): void {
446
+ for (const name of new Set([...served.keys(), ...incoming.keys()])) {
447
+ const before = served.get(name) ?? null;
448
+ const after = incoming.get(name) ?? null;
449
+ if (before !== after) applyServedChange(d, target, name, before, after);
450
+ }
451
+ }
412
452
 
413
453
  function syncHtmlAttributes(d: Document, next: Document, servedProperty: string): void {
414
- const root = d.documentElement;
415
- const incoming = next.documentElement;
416
- const served =
417
- serverHtmlAttributes.get(d) ??
418
- ((root as unknown as Record<string, readonly string[] | undefined>)[servedProperty] || []);
419
- for (const name of served)
420
- if (!incoming.hasAttribute(name)) root.removeAttribute(name);
421
- for (const attribute of incoming.attributes)
422
- if (root.getAttribute(attribute.name) !== attribute.value)
423
- root.setAttribute(attribute.name, attribute.value);
424
- serverHtmlAttributes.set(d, new Set(incoming.getAttributeNames()));
454
+ const incoming = attributePairs(next.documentElement);
455
+ applyServedChanges(d, d.documentElement, servedBefore(d, 'html', servedProperty), incoming);
456
+ servedAttributes.html.set(d, incoming);
457
+ }
458
+
459
+ // A shell <body> the swap morphs in place takes only the server's changes to its attributes.
460
+ function syncBodyAttributes(d: Document, next: Document, servedProperty: string): void {
461
+ const incoming = attributePairs(next.body);
462
+ applyServedChanges(d, d.body, servedBefore(d, 'body', servedProperty), incoming);
463
+ servedAttributes.body.set(d, incoming);
464
+ }
465
+
466
+ // A container <body> is replaced whole: the incoming one takes the outgoing's attributes plus the server's changes, its own container attributes as served.
467
+ function carryBodyAttributes(
468
+ d: Document,
469
+ from: HTMLElement,
470
+ to: HTMLElement,
471
+ own: readonly string[],
472
+ servedProperty: string,
473
+ ): void {
474
+ const incoming = attributePairs(to);
475
+ const merged = d.createElement(from.localName);
476
+ for (const attribute of from.attributes) merged.setAttribute(attribute.name, attribute.value);
477
+ applyServedChanges(d, merged, servedBefore(d, 'body', servedProperty), incoming);
478
+ for (const name of to.getAttributeNames())
479
+ if (!own.includes(name) && !merged.hasAttribute(name)) to.removeAttribute(name);
480
+ for (const attribute of merged.attributes)
481
+ if (!own.includes(attribute.name) && to.getAttribute(attribute.name) !== attribute.value)
482
+ to.setAttribute(attribute.name, attribute.value);
483
+ servedAttributes.body.set(d, incoming);
484
+ }
485
+
486
+ // class and style are merged a token and a declaration at a time, so a script's own tokens are never dropped.
487
+ function applyServedChange(
488
+ d: Document,
489
+ root: HTMLElement,
490
+ name: string,
491
+ before: string | null,
492
+ after: string | null,
493
+ ): void {
494
+ if (name === 'class') {
495
+ const was = classTokens(before);
496
+ const will = classTokens(after);
497
+ const removed = [...was].filter((token) => !will.has(token));
498
+ const added = [...will].filter((token) => !was.has(token));
499
+ if (removed.length) root.classList.remove(...removed);
500
+ if (added.length) root.classList.add(...added);
501
+ } else if (name === 'style') {
502
+ const was = declarations(d, before);
503
+ const will = declarations(d, after);
504
+ for (const property of was.keys())
505
+ if (!will.has(property)) root.style.removeProperty(property);
506
+ for (const [property, [value, priority]] of will) {
507
+ const old = was.get(property);
508
+ if (old?.[0] !== value || old[1] !== priority)
509
+ root.style.setProperty(property, value, priority);
510
+ }
511
+ } else if (after === null) root.removeAttribute(name);
512
+ else root.setAttribute(name, after);
513
+ if (after === null && !root.getAttribute(name)?.trim()) root.removeAttribute(name);
514
+ }
515
+
516
+ const classTokens = (value: string | null): Set<string> =>
517
+ new Set(value?.split(/\s+/).filter(Boolean));
518
+
519
+ function declarations(d: Document, value: string | null): Map<string, [string, string]> {
520
+ const style = d.createElement('div').style;
521
+ style.cssText = value ?? '';
522
+ const result = new Map<string, [string, string]>();
523
+ for (let index = 0; index < style.length; index++) {
524
+ const property = style.item(index);
525
+ result.set(property, [
526
+ style.getPropertyValue(property),
527
+ style.getPropertyPriority(property),
528
+ ]);
529
+ }
530
+ return result;
425
531
  }
426
532
 
427
533
  // Set from the page's config at the start of every swap.
@@ -621,14 +727,14 @@ async function streamAppends(
621
727
  return signal.aborted ? undefined : all;
622
728
  }
623
729
 
624
- // Inserting a big page whole lays it out in one long frame; the HTML parser yields while it streams a document, and the swap does the same.
625
- const FRAME_ELEMENTS = 800;
730
+ // Inserting a big page whole lays it out in one long task; the swap lays it out this many elements at a time and yields between.
731
+ const CHUNK_ELEMENTS = 800;
626
732
 
627
733
  type HeldPage = [parent: Node, before: Node | null, nodes: ChildNode[]][];
628
734
 
629
735
  function holdPage(parent: Element, after: Node | undefined, before: Node | null): HeldPage {
630
736
  const nodes: ChildNode[] = [];
631
- for (let node = after ? after.nextSibling : parent.firstChild; node && node !== before; ) {
737
+ for (let node = after ? after.nextSibling : parent.firstChild; node && node !== before;) {
632
738
  const next: ChildNode | null = node.nextSibling;
633
739
  nodes.push(node);
634
740
  node.remove();
@@ -640,32 +746,40 @@ function holdPage(parent: Element, after: Node | undefined, before: Node | null)
640
746
  const elementsIn = (node: Node) =>
641
747
  node.nodeType === 1 ? (node as Element).getElementsByTagName('*').length + 1 : 0;
642
748
 
643
- // Places a frame's worth synchronously, so the first paint shows the top of the page; true when everything is in.
749
+ // Runs while the leaving page is held on screen; each chunk is laid out before yielding and an element that fits in one goes in whole.
644
750
  function fillPage(
645
751
  w: Window,
646
752
  held: HeldPage,
647
753
  budget: number,
648
754
  current: () => boolean,
649
755
  ): true | Promise<boolean> {
756
+ const laidOut = () => {
757
+ void w.document.documentElement.offsetHeight;
758
+ return new Promise<boolean>((resolve) => {
759
+ const channel = new MessageChannel();
760
+ channel.port1.onmessage = () => resolve(current());
761
+ channel.port2.postMessage(0);
762
+ });
763
+ };
764
+ const nextChunk = () => laidOut().then((still) => still && fillPage(w, held, budget, current));
650
765
  let used = 0;
651
766
  while (held.length) {
652
767
  const [parent, before, nodes] = held[0]!;
768
+ const size = elementsIn(nodes[0]!);
769
+ if (used > 0 && used + size > budget && size <= budget) return nextChunk();
653
770
  const node = nodes.shift()!;
654
771
  if (!nodes.length) held.shift();
655
- const size = elementsIn(node);
656
- if (used + size > budget && node.firstChild) {
772
+ if (size > budget && node.firstChild) {
657
773
  const children = [...node.childNodes];
658
774
  (node as Element).replaceChildren();
659
775
  held.unshift([node, null, children]);
660
776
  used += 1;
661
777
  } else used += size;
662
778
  parent.insertBefore(node, before);
663
- if (used >= budget && held.length)
664
- return new Promise<void>((resolve) =>
665
- w.requestAnimationFrame(() => w.setTimeout(resolve, 0)),
666
- ).then(() => current() && fillPage(w, held, budget, current));
779
+ if (used >= budget && held.length) return nextChunk();
667
780
  }
668
- return true;
781
+ // The last chunk is laid out in its own task too, so what runs once the page is in starts on a laid-out page.
782
+ return budget === Infinity ? true : laidOut();
669
783
  }
670
784
 
671
785
  export async function navigate(
@@ -739,89 +853,128 @@ export async function navigate(
739
853
  if (mode === 'push') w.history.pushState(entry, '', landed.href);
740
854
  else w.history.replaceState(entry, '', landed.href);
741
855
  }
742
- const offsets: ScrolledBox[] = [];
743
- for (const element of state.scrolled) {
744
- if (!element.isConnected || !scope.contains(element)) continue;
745
- if (element.scrollTop || element.scrollLeft)
746
- offsets.push({
747
- path: elementPath(scope, element),
748
- id: element.id,
749
- kind: boxKind(element),
750
- top: element.scrollTop,
751
- left: element.scrollLeft,
752
- });
753
- }
754
- state.scrolled.clear();
755
- retire(state, outgoing);
756
- (w as unknown as Record<string, unknown>)[state.config.streamExecutorGlobal] = undefined;
757
- for (const element of d.querySelectorAll(state.config.streamedArmSelector)) element.remove();
758
- state.shown = pageKey(new URL(w.location.href));
759
- if (documentLoads) syncHtmlAttributes(d, next, state.config.servedHtmlAttributesProperty);
760
- let region: Element;
761
- let end: Comment;
762
- let start: Comment | undefined;
763
- let scripts: Element[];
764
- let held: HeldPage | undefined;
765
- if (documentLoads && !isContainer(d.body)) {
766
- const fresh: Element[] = [];
767
- region = d.body;
768
- morphChildren(d, region, incoming, fresh);
769
- scripts = fresh.flatMap(scriptsIn);
770
- region.append((end = d.createComment('')));
771
- } else {
772
- region = d.adoptNode(incoming);
773
- const leavingEnd = pageEnd(outgoing, state.config.pageEndComment);
774
- if (leavingEnd) {
775
- carryHostAttributes(d, outgoing, region, state.config.containerAttributes);
776
- end =
777
- pageEnd(region, state.config.pageEndComment) ??
778
- region.appendChild(d.createComment(state.config.pageEndComment));
779
- // The document's own nodes after the page stay; a document app morphs them to its next shell.
780
- const focused = d.activeElement as HTMLElement | null;
781
- const kept = nodesAfter(d, leavingEnd);
782
- const arriving = nodesAfter(d, end);
856
+ // A scroll target or a restored position needs the whole page in place before it is applied; a page that fits in a chunk goes in at once.
857
+ const progressive =
858
+ restore === 'reset' &&
859
+ !url.hash &&
860
+ d.visibilityState !== 'hidden' &&
861
+ typeof d.startViewTransition === 'function' &&
862
+ incoming.getElementsByTagName('*').length > CHUNK_ELEMENTS;
863
+ let region!: Element;
864
+ let end!: Comment;
865
+ let scripts: Element[] = [];
866
+ const commit = async (): Promise<boolean> => {
867
+ if (id !== session.navigation) return false;
868
+ const offsets: ScrolledBox[] = [];
869
+ for (const element of state.scrolled) {
870
+ if (!element.isConnected || !scope.contains(element)) continue;
871
+ if (element.scrollTop || element.scrollLeft)
872
+ offsets.push({
873
+ path: elementPath(scope, element),
874
+ id: element.id,
875
+ kind: boxKind(element),
876
+ top: element.scrollTop,
877
+ left: element.scrollLeft,
878
+ });
879
+ }
880
+ state.scrolled.clear();
881
+ retire(state, outgoing);
882
+ (w as unknown as Record<string, unknown>)[state.config.streamExecutorGlobal] = undefined;
883
+ for (const element of d.querySelectorAll(state.config.streamedArmSelector))
884
+ element.remove();
885
+ state.shown = pageKey(new URL(w.location.href));
886
+ if (documentLoads) syncHtmlAttributes(d, next, state.config.servedHtmlAttributesProperty);
887
+ if (documentLoads && !isContainer(d.body))
888
+ syncBodyAttributes(d, next, state.config.servedBodyAttributesProperty);
889
+ let start: Comment | undefined;
890
+ let held: HeldPage | undefined;
891
+ if (documentLoads && !isContainer(d.body)) {
783
892
  const fresh: Element[] = [];
784
- if (documentLoads) morphChildren(d, kept, arriving, fresh);
785
- const leavingStart = pageStart(outgoing, state.config.pageStartComment);
786
- start = pageStart(region, state.config.pageStartComment);
787
- if (leavingStart || start) {
788
- start ??= region.insertBefore(
789
- d.createComment(state.config.pageStartComment),
790
- region.firstChild,
893
+ region = d.body;
894
+ morphChildren(d, region, incoming, fresh);
895
+ scripts = fresh.flatMap(scriptsIn);
896
+ region.append((end = d.createComment('')));
897
+ } else {
898
+ region = d.adoptNode(incoming);
899
+ if (outgoing === d.body)
900
+ carryBodyAttributes(
901
+ d,
902
+ outgoing as HTMLElement,
903
+ region as HTMLElement,
904
+ state.config.containerAttributes,
905
+ state.config.servedBodyAttributesProperty,
791
906
  );
792
- const keptBefore = leavingStart
793
- ? nodesBefore(d, leavingStart)
794
- : d.createDocumentFragment();
795
- const arrivingBefore = nodesBefore(d, start);
796
- if (documentLoads) morphChildren(d, keptBefore, arrivingBefore, fresh);
797
- region.insertBefore(keptBefore, start);
907
+ const leavingEnd = pageEnd(outgoing, state.config.pageEndComment);
908
+ if (leavingEnd) {
909
+ if (outgoing !== d.body)
910
+ carryHostAttributes(d, outgoing, region, state.config.containerAttributes);
911
+ end =
912
+ pageEnd(region, state.config.pageEndComment) ??
913
+ region.appendChild(d.createComment(state.config.pageEndComment));
914
+ // The document's own nodes after the page stay; a document app morphs them to its next shell.
915
+ const focused = d.activeElement as HTMLElement | null;
916
+ const kept = nodesAfter(d, leavingEnd);
917
+ const arriving = nodesAfter(d, end);
918
+ const fresh: Element[] = [];
919
+ if (documentLoads) morphChildren(d, kept, arriving, fresh);
920
+ const leavingStart = pageStart(outgoing, state.config.pageStartComment);
921
+ start = pageStart(region, state.config.pageStartComment);
922
+ if (leavingStart || start) {
923
+ start ??= region.insertBefore(
924
+ d.createComment(state.config.pageStartComment),
925
+ region.firstChild,
926
+ );
927
+ const keptBefore = leavingStart
928
+ ? nodesBefore(d, leavingStart)
929
+ : d.createDocumentFragment();
930
+ const arrivingBefore = nodesBefore(d, start);
931
+ if (documentLoads) morphChildren(d, keptBefore, arrivingBefore, fresh);
932
+ region.insertBefore(keptBefore, start);
933
+ }
934
+ scripts = [...scriptsIn(region), ...fresh.flatMap(scriptsIn)];
935
+ region.append(kept);
936
+ held = holdPage(region, start, end);
937
+ outgoing.replaceWith(region);
938
+ // The document's own nodes moved to the new element; focus inside them stays put.
939
+ if (focused && focused !== d.activeElement && region.contains(focused))
940
+ focused.focus({ preventScroll: true });
941
+ } else {
942
+ scripts = scriptsIn(region);
943
+ held = holdPage(region, undefined, null);
944
+ outgoing.replaceWith(region);
945
+ region.after((end = d.createComment('')));
798
946
  }
799
- scripts = [...scriptsIn(region), ...fresh.flatMap(scriptsIn)];
800
- region.append(kept);
801
- held = holdPage(region, start, end);
802
- outgoing.replaceWith(region);
803
- // The document's own nodes moved to the new element; focus inside them stays put.
804
- if (focused && focused !== d.activeElement && region.contains(focused))
805
- focused.focus({ preventScroll: true });
806
- } else {
807
- scripts = scriptsIn(region);
808
- held = holdPage(region, undefined, null);
809
- outgoing.replaceWith(region);
810
- region.after((end = d.createComment('')));
811
947
  }
812
- }
813
- // A scroll target or a restored position needs the whole page in place before it is applied.
814
- const progressive = restore === 'reset' && !url.hash && d.visibilityState !== 'hidden';
815
- const filled = held && fillPage(w, held, progressive ? FRAME_ELEMENTS : Infinity, () => id === session.navigation);
816
- if (filled instanceof Promise) {
817
- w.scrollTo({ top: 0, left: 0, behavior: 'instant' });
818
- if (!(await filled)) return;
819
- }
820
- // 'instant': a `scroll-behavior: smooth` page would otherwise animate every landing.
821
- for (const box of offsets)
822
- sameBox(scope, box)?.scrollTo({ top: box.top, left: box.left, behavior: 'instant' });
823
- for (const element of head.stale) element.remove();
824
- if (next.title && d.title !== next.title) d.title = next.title;
948
+ const filled =
949
+ held &&
950
+ fillPage(
951
+ w,
952
+ held,
953
+ progressive ? CHUNK_ELEMENTS : Infinity,
954
+ () => id === session.navigation,
955
+ );
956
+ if (filled instanceof Promise && !(await filled)) return false;
957
+ if (progressive) w.scrollTo({ top: 0, left: 0, behavior: 'instant' });
958
+ // 'instant': a `scroll-behavior: smooth` page would otherwise animate every landing.
959
+ for (const box of offsets)
960
+ sameBox(scope, box)?.scrollTo({ top: box.top, left: box.left, behavior: 'instant' });
961
+ for (const element of head.stale) element.remove();
962
+ if (next.title && d.title !== next.title) d.title = next.title;
963
+ return true;
964
+ };
965
+ let committed: boolean;
966
+ if (progressive) {
967
+ // The leaving page stays on screen while the next one is laid out a chunk at a time; it then paints in one frame, with no animation.
968
+ let result = false;
969
+ const transition = d.startViewTransition(async () => {
970
+ result = await commit();
971
+ });
972
+ transition.ready.catch(() => {});
973
+ transition.finished.catch(() => {});
974
+ await transition.updateCallbackDone.then(() => transition.skipTransition(), reportAsync);
975
+ committed = result;
976
+ } else committed = await commit();
977
+ if (!committed) return;
825
978
  const ran = runScripts(
826
979
  d,
827
980
  [...head.scripts, ...(scripts as HTMLScriptElement[])],
@@ -834,7 +987,7 @@ export async function navigate(
834
987
  if (restore === 'reset') {
835
988
  const target = url.hash ? d.getElementById(decodeURIComponent(url.hash.slice(1))) : null;
836
989
  if (target) target.scrollIntoView({ behavior: 'instant' });
837
- else if (!(filled instanceof Promise)) w.scrollTo({ top: 0, left: 0, behavior: 'instant' });
990
+ else if (!progressive) w.scrollTo({ top: 0, left: 0, behavior: 'instant' });
838
991
  } else if (restore !== 'manual')
839
992
  w.scrollTo({ top: restore.y, left: restore.x, behavior: 'instant' });
840
993
  d.dispatchEvent(new CustomEvent('marklessrouterrendered'));