@ultimat3/render 22.7.1 → 22.8.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,306 @@
1
+ /**
2
+ * Which clicks and submits the client router takes, and which it leaves to the browser — as pure
3
+ * functions over plain facts, so every rule is testable without a DOM and the router
4
+ * (`navigation.ts`) only gathers the facts. Also the one declaration of every attribute, header and
5
+ * event name the router shares with the documents the server renders and the apps that listen.
6
+ */
7
+
8
+ import {
9
+ CLIENT_NAVIGATION_HEADER,
10
+ CLIENT_NAVIGATION_LOCATION_HEADER,
11
+ CLIENT_NAVIGATION_SCOPE_HEADER,
12
+ CLIENT_NAVIGATION_SURFACE_HEADER,
13
+ } from '@ultimat3/core/page';
14
+
15
+ /**
16
+ * `<meta name="ultimate-navigation" content="<app>:<surface>">` — present only on an opted-in
17
+ * surface. The app's name is part of it, so two apps on one origin (web and admin) are two routers.
18
+ */
19
+ export const NAVIGATION_META = 'ultimate-navigation';
20
+ /**
21
+ * Sent on every router fetch: `soft` when a visitor asked for the page, `prefetch` when the router
22
+ * guessed they would. The server answers a route that did not opt in before running it.
23
+ */
24
+ export const NAVIGATION_HEADER = CLIENT_NAVIGATION_HEADER;
25
+ /** The `<app>:<surface>` of the document the router runs in, on every router fetch. */
26
+ export const NAVIGATION_SURFACE_HEADER = CLIENT_NAVIGATION_SURFACE_HEADER;
27
+ /** The document's principal (`ultimate-scope`), when it carries one. */
28
+ export const NAVIGATION_SCOPE_HEADER = CLIENT_NAVIGATION_SCOPE_HEADER;
29
+ /** On a `204`: load this URL with a real navigation — the server's hand-over. */
30
+ export const NAVIGATION_LOCATION_HEADER = CLIENT_NAVIGATION_LOCATION_HEADER;
31
+ /** On a link or form: always a full document load. */
32
+ export const NAVIGATION_RELOAD_ATTRIBUTE = 'data-x-reload';
33
+ /** On a link: followed softly, never fetched before it is clicked. */
34
+ export const NAVIGATION_NO_PREFETCH_ATTRIBUTE = 'data-x-no-prefetch';
35
+ /** `data-x-persist="<id>"` on an element in both documents: the live element is kept across. */
36
+ export const NAVIGATION_PERSIST_ATTRIBUTE = 'data-x-persist';
37
+ /** Set on `<html>` while a navigation has run past `NAVIGATION_PROGRESS_DELAY_MS`. */
38
+ export const NAVIGATING_ATTRIBUTE = 'data-x-navigating';
39
+ /** Dispatched on `document`, cancelable, before the fetch: `detail: { url, method }`. */
40
+ export const NAVIGATE_EVENT = 'ultimate:navigate';
41
+ /** Dispatched on `document` after the new document is in place: `detail: { url }`. */
42
+ export const NAVIGATED_EVENT = 'ultimate:navigated';
43
+ /**
44
+ * Dispatched on `document`, cancelable, when a navigation could not complete: `detail: { url,
45
+ * method, reason }`. Default action: a GET load of the page the visitor is on — a POST is never
46
+ * sent twice, and after a failed one only the server knows whether it landed.
47
+ */
48
+ export const NAVIGATION_ERROR_EVENT = 'ultimate:navigation-error';
49
+
50
+ /** How long a prefetched or visited document answers a navigation to the same URL. */
51
+ export const NAVIGATION_CACHE_TTL_MS = 30_000;
52
+ /** Pointer rest before a prefetch — long enough that sweeping across a menu fetches nothing. */
53
+ export const NAVIGATION_PREFETCH_DELAY_MS = 65;
54
+ /** Below this, a navigation shows no progress at all: a flash of a bar reads as jank. */
55
+ export const NAVIGATION_PROGRESS_DELAY_MS = 150;
56
+ /** How long a prefetched answer marked `no-store` may still answer the click it anticipated. */
57
+ export const NAVIGATION_NO_STORE_REUSE_MS = 5_000;
58
+ /** Redirects the router follows itself before it hands the rest to the browser. */
59
+ export const NAVIGATION_MAX_HOPS = 5;
60
+
61
+ /** Why the browser keeps a navigation. Named, so a test states the rule it pins. */
62
+ export type NativeReason =
63
+ | 'prevented'
64
+ | 'modified'
65
+ | 'target'
66
+ | 'download'
67
+ | 'external'
68
+ | 'opt-out'
69
+ | 'cross-origin'
70
+ | 'hash'
71
+ | 'method'
72
+ | 'encoding'
73
+ | 'file';
74
+
75
+ export type LinkVerdict =
76
+ | { readonly kind: 'soft'; readonly url: string }
77
+ | { readonly kind: 'native'; readonly reason: NativeReason };
78
+
79
+ /** What a click on an `<a href>` tells the router. `href` is the RESOLVED `a.href`. */
80
+ export interface LinkFacts {
81
+ readonly href: string;
82
+ /** `location.href` of the page the link sits on. */
83
+ readonly current: string;
84
+ readonly button: number;
85
+ readonly modified: boolean;
86
+ readonly defaultPrevented: boolean;
87
+ /** `a.target`, `''` when unset. `_self` is the one value that stays in this tab. */
88
+ readonly target: string;
89
+ readonly download: boolean;
90
+ /** `a.rel`, space-separated. */
91
+ readonly rel: string;
92
+ readonly reload: boolean;
93
+ }
94
+
95
+ const native = (reason: NativeReason): { readonly kind: 'native'; reason: NativeReason } => ({
96
+ kind: 'native',
97
+ reason,
98
+ });
99
+
100
+ /** Same document, fragment only: the browser scrolls, no fetch. */
101
+ const hashOnly = (to: URL, from: URL): boolean =>
102
+ to.hash !== '' && to.pathname === from.pathname && to.search === from.search;
103
+
104
+ /**
105
+ * The link rules, in the order a reader checks them. The SURFACE is not decided here: the URL
106
+ * alone cannot say which surface answers it, so the router fetches and compares the answer's
107
+ * `ultimate-navigation` meta with this document's (`responseVerdict`).
108
+ */
109
+ export function linkVerdict(facts: LinkFacts): LinkVerdict {
110
+ if (facts.defaultPrevented) return native('prevented');
111
+ if (facts.button !== 0 || facts.modified) return native('modified');
112
+ if (facts.target !== '' && facts.target !== '_self') return native('target');
113
+ if (facts.download) return native('download');
114
+ if (facts.rel.split(/\s+/).includes('external')) return native('external');
115
+ if (facts.reload) return native('opt-out');
116
+ const to = new URL(facts.href);
117
+ const from = new URL(facts.current);
118
+ if (to.origin !== from.origin || (to.protocol !== 'http:' && to.protocol !== 'https:')) {
119
+ return native('cross-origin');
120
+ }
121
+ if (hashOnly(to, from)) return native('hash');
122
+ return { kind: 'soft', url: to.href };
123
+ }
124
+
125
+ /** What a hover or focus on a link tells the router, before anyone has clicked. */
126
+ export interface PrefetchFacts {
127
+ /** The URL `linkVerdict` would follow softly. */
128
+ readonly url: string;
129
+ readonly noPrefetch: boolean;
130
+ /** `navigator.connection` — absent in a browser that does not say. */
131
+ readonly saveData?: boolean | undefined;
132
+ readonly effectiveType?: string | undefined;
133
+ }
134
+
135
+ /** The connections a guess costs too much on: every byte of a wrong one is the visitor's. */
136
+ const SLOW_CONNECTIONS: ReadonlySet<string> = new Set(['slow-2g', '2g']);
137
+
138
+ /**
139
+ * Whether the router may ASK for `url` before it is clicked. Only asking: the server answers a
140
+ * prefetch of any route that did not declare `navigation: 'prefetch'` with an empty `204` before
141
+ * it runs anything (`@ultimat3/http`'s navigation gate), so a GET that records something — a
142
+ * download, a click, a token opened — is never executed by a hover, whatever its path is called.
143
+ */
144
+ export function mayPrefetch(facts: PrefetchFacts): boolean {
145
+ if (facts.noPrefetch || facts.saveData === true) return false;
146
+ return facts.effectiveType === undefined || !SLOW_CONNECTIONS.has(facts.effectiveType);
147
+ }
148
+
149
+ export type FormVerdict =
150
+ | { readonly kind: 'get'; readonly url: string }
151
+ | { readonly kind: 'post'; readonly url: string; readonly encoding: 'urlencoded' | 'multipart' }
152
+ | { readonly kind: 'native'; readonly reason: NativeReason };
153
+
154
+ /** What a `submit` tells the router — the submitter's `formmethod`/`formaction` already applied. */
155
+ export interface FormFacts {
156
+ /** Resolved action URL. */
157
+ readonly action: string;
158
+ readonly current: string;
159
+ /** Lower case: `get`, `post` or `dialog`. */
160
+ readonly method: string;
161
+ readonly enctype: string;
162
+ readonly target: string;
163
+ readonly defaultPrevented: boolean;
164
+ readonly reload: boolean;
165
+ /** A file input with a file chosen: an upload is the browser's, with its own progress. */
166
+ readonly hasFile: boolean;
167
+ /** The fields, for a GET — the query the browser would have built. */
168
+ readonly fields: readonly (readonly [string, string])[];
169
+ }
170
+
171
+ export function formVerdict(facts: FormFacts): FormVerdict {
172
+ if (facts.defaultPrevented) return native('prevented');
173
+ if (facts.target !== '' && facts.target !== '_self') return native('target');
174
+ if (facts.reload) return native('opt-out');
175
+ if (facts.method !== 'get' && facts.method !== 'post') return native('method');
176
+ // `text/plain` is the browser's own encoding, with no `fetch` body that reproduces it byte for byte.
177
+ if (facts.method === 'post' && facts.enctype === 'text/plain') return native('encoding');
178
+ const to = new URL(facts.action);
179
+ if (to.origin !== new URL(facts.current).origin) return native('cross-origin');
180
+ if (facts.method === 'get') {
181
+ // The browser REPLACES the action's query with the fields, and so does this.
182
+ to.search = new URLSearchParams(facts.fields.map(([k, v]) => [k, v])).toString();
183
+ return { kind: 'get', url: to.href };
184
+ }
185
+ if (facts.hasFile) return native('file');
186
+ return {
187
+ kind: 'post',
188
+ url: to.href,
189
+ encoding: facts.enctype === 'multipart/form-data' ? 'multipart' : 'urlencoded',
190
+ };
191
+ }
192
+
193
+ export type ResponseVerdict =
194
+ /** Put the answer in place of this document. */
195
+ | { readonly kind: 'swap' }
196
+ /** A redirect the server handed over, on this origin: the router's own next request. */
197
+ | { readonly kind: 'follow'; readonly url: string }
198
+ /** A real navigation to `url`: the browser asks for it, once. */
199
+ | { readonly kind: 'load'; readonly url: string; readonly reason: string }
200
+ /** Not a page: the bytes already received go to the browser — saved or shown, never re-fetched. */
201
+ | { readonly kind: 'hand-over' }
202
+ /** A `204` with nowhere to go: the browser stays where it is, and so does the router. */
203
+ | { readonly kind: 'stay' }
204
+ /** A POST whose outcome cannot be shown: `NAVIGATION_ERROR_EVENT`, then a GET of this page. */
205
+ | { readonly kind: 'failed'; readonly reason: string };
206
+
207
+ /** What the router knows once an answer arrives, read off the response and both documents. */
208
+ export interface ResponseFacts {
209
+ readonly method: 'GET' | 'POST';
210
+ /** The URL this request asked for. */
211
+ readonly requested: string;
212
+ readonly status: number;
213
+ /** `redirect: 'manual'` met a redirect the server did not hand over (not a framework server). */
214
+ readonly opaqueRedirect: boolean;
215
+ /** `x-ultimate-location` on a `204`, resolved. */
216
+ readonly location: string | null;
217
+ readonly contentType: string;
218
+ /** Redirects the router already followed for this navigation. */
219
+ readonly hops: number;
220
+ /** This document's `<app>:<surface>`, and the answer's (`null` when it carries none). */
221
+ readonly surface: string | null;
222
+ readonly nextSurface: string | null;
223
+ /** `x-ultimate-build`: this document's, and the answer's header or meta. */
224
+ readonly build: string | null;
225
+ readonly nextBuild: string | null;
226
+ /** `ultimate-scope`: whose document this is. Absent means "rendered for nobody". */
227
+ readonly scope: string | null;
228
+ readonly nextScope: string | null;
229
+ }
230
+
231
+ const origin = (url: string): string => new URL(url).origin;
232
+ const withoutFragment = (url: string): string => url.split('#')[0] ?? url;
233
+
234
+ /**
235
+ * What to do with an answer. Every branch keeps one rule: nothing the router sent is sent again.
236
+ * A GET is loaded again only where no framework server answered it (an opaque redirect or skew on
237
+ * a static host); a POST never — after one, only the server knows what landed.
238
+ */
239
+ export function responseVerdict(facts: ResponseFacts): ResponseVerdict {
240
+ const get = facts.method === 'GET';
241
+ const load = (url: string, reason: string): ResponseVerdict => ({ kind: 'load', url, reason });
242
+ if (facts.opaqueRedirect) {
243
+ return get
244
+ ? load(facts.requested, 'a redirect no framework server handed over')
245
+ : { kind: 'failed', reason: 'a redirect no framework server handed over' };
246
+ }
247
+ if (facts.status === 204) {
248
+ if (facts.location === null) return { kind: 'stay' };
249
+ if (origin(facts.location) !== origin(facts.requested))
250
+ return load(facts.location, 'another origin');
251
+ // The server asked for THIS url as a document: the route did not run, the browser runs it.
252
+ if (get && withoutFragment(facts.location) === withoutFragment(facts.requested)) {
253
+ return load(facts.location, 'the server asked for a document load');
254
+ }
255
+ if (facts.hops >= NAVIGATION_MAX_HOPS) return load(facts.location, 'too many redirects');
256
+ return { kind: 'follow', url: facts.location };
257
+ }
258
+ const skewed =
259
+ facts.build !== null && facts.nextBuild !== null && facts.build !== facts.nextBuild;
260
+ // A GET refused for skew (409 before any route ran) or rendered by another build: load it.
261
+ if (get && skewed) return load(facts.requested, 'another build');
262
+ if (!/^\s*text\/html\b/i.test(facts.contentType)) return { kind: 'hand-over' };
263
+ // A POST answered in place IS the page the browser would have shown, whatever rendered it —
264
+ // and when that is another principal's or build's, the caller stops trusting this tab
265
+ // (`answerMovesTab`): every later navigation is a real load.
266
+ if (!get) return { kind: 'swap' };
267
+ // An error page is the answer, whatever shell rendered it (the framework's own carries no
268
+ // surface meta): a load that wrote and then threw must not run a second time.
269
+ if (facts.status >= 400) return { kind: 'swap' };
270
+ if (facts.nextSurface === null || facts.nextSurface !== facts.surface) {
271
+ return load(facts.requested, 'another surface');
272
+ }
273
+ if (facts.scope !== facts.nextScope) return load(facts.requested, 'another principal');
274
+ return { kind: 'swap' };
275
+ }
276
+
277
+ /** A cached answer, as far as reuse is concerned. */
278
+ export interface CachedFacts {
279
+ readonly status: number;
280
+ readonly html: boolean;
281
+ readonly location: string | null;
282
+ readonly noStore: boolean;
283
+ readonly ageMs: number;
284
+ }
285
+
286
+ /**
287
+ * Whether a prefetched answer may stand in for the click: a 2xx page, nothing handed over, and —
288
+ * when the server said `no-store` — only within `NAVIGATION_NO_STORE_REUSE_MS` of being fetched.
289
+ * A failure, a redirect, an empty `204` is never an answer to reuse.
290
+ */
291
+ export function reusable(facts: CachedFacts): boolean {
292
+ if (facts.status < 200 || facts.status > 299 || facts.status === 204) return false;
293
+ if (!facts.html || facts.location !== null) return false;
294
+ return !facts.noStore || facts.ageMs < NAVIGATION_NO_STORE_REUSE_MS;
295
+ }
296
+
297
+ /**
298
+ * An answer swapped in although it belongs to another principal or build (a POST answered in place,
299
+ * which cannot be asked for again). The tab's fence, its cache and its islands were set up for the
300
+ * old one, so after it every navigation is a real document load, and every tab's cache is emptied.
301
+ */
302
+ export function answerMovesTab(facts: ResponseFacts): boolean {
303
+ const skewed =
304
+ facts.build !== null && facts.nextBuild !== null && facts.build !== facts.nextBuild;
305
+ return skewed || facts.scope !== facts.nextScope;
306
+ }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Puts a fetched document in place of the current one, in one order: its stylesheets loaded (no
3
+ * unstyled frame), this page's islands disposed, the body replaced with persisted elements carried
4
+ * across, the head brought level, and the document's own scripts run — which re-runs the ONE
5
+ * island bootstrap (`hydrate.ts`'s runtime) over the new body. There is no second mount path.
6
+ */
7
+
8
+ import { NAVIGATION_PERSIST_ATTRIBUTE } from './navigation-rules';
9
+
10
+ /** What `hydrate.ts`'s runtime leaves on an island root: the boot promise and the visited mark. */
11
+ interface IslandElement extends Element {
12
+ __x?: Promise<unknown>;
13
+ }
14
+
15
+ const ISLAND_SELECTOR = '[data-x-island]';
16
+ const STYLESHEET = 'link[rel~="stylesheet"]';
17
+
18
+ const isStylesheet = (el: Element): boolean =>
19
+ el.tagName === 'LINK' && /\bstylesheet\b/i.test(el.getAttribute('rel') ?? '');
20
+
21
+ /** A script type the parser executes — JSON (island props, JSON-LD) is data. */
22
+ const executes = (script: Element): boolean =>
23
+ script.tagName === 'SCRIPT' && !/json$/i.test(script.getAttribute('type') ?? '');
24
+
25
+ /**
26
+ * Diffed by markup: everything but a script that executes (it ran, or runs through `runScripts`)
27
+ * and a stylesheet (loaded before the swap, retired after it). JSON-LD is data, so it is replaced.
28
+ */
29
+ const isDiffed = (el: Element): boolean => !executes(el) && !isStylesheet(el);
30
+
31
+ /**
32
+ * The head, brought level by MARKUP: a tag identical in both stays (no reflow, no refetch), one
33
+ * only this document has goes, one only the next has arrives. Covers every route-owned tag —
34
+ * title, description, robots, canonical, alternates, Open Graph, JSON-LD — without a list of
35
+ * names that the next SEO tag would be missing from.
36
+ */
37
+ export function headPlan(
38
+ current: readonly Element[],
39
+ next: readonly Element[],
40
+ ): { readonly remove: readonly Element[]; readonly add: readonly Element[] } {
41
+ const have = new Set(current.filter(isDiffed).map((el) => el.outerHTML));
42
+ const want = new Set(next.filter(isDiffed).map((el) => el.outerHTML));
43
+ return {
44
+ remove: current.filter((el) => isDiffed(el) && !want.has(el.outerHTML)),
45
+ add: next.filter((el) => isDiffed(el) && !have.has(el.outerHTML)),
46
+ };
47
+ }
48
+
49
+ /** The next document's stylesheets this one has not loaded, as hrefs. */
50
+ export function missingStylesheets(doc: Document, next: Document): readonly string[] {
51
+ const loaded = new Set(
52
+ [...doc.querySelectorAll<HTMLLinkElement>(STYLESHEET)].map((link) => link.href),
53
+ );
54
+ return [...next.querySelectorAll<HTMLLinkElement>(STYLESHEET)]
55
+ .map((link) => new URL(link.getAttribute('href') ?? '', doc.baseURI).href)
56
+ .filter((href) => !loaded.has(href));
57
+ }
58
+
59
+ /** Resolves once every sheet loaded OR failed — a missing sheet must not hang the navigation. */
60
+ export function loadStylesheets(doc: Document, hrefs: readonly string[]): Promise<void> {
61
+ return Promise.all(
62
+ hrefs.map(
63
+ (href) =>
64
+ new Promise<void>((done) => {
65
+ const link = doc.createElement('link');
66
+ link.rel = 'stylesheet';
67
+ link.href = href;
68
+ link.onload = () => done();
69
+ link.onerror = () => done();
70
+ doc.head.append(link);
71
+ }),
72
+ ),
73
+ ).then(() => undefined);
74
+ }
75
+
76
+ /**
77
+ * Calls each island's disposer — what its `mount` returned, held by `el.__x` — unless the island
78
+ * is carried across. A rejected boot has nothing to dispose, and the rejection is already on the
79
+ * element as `data-x-failed`.
80
+ */
81
+ export function disposeIslands(root: Element, kept: (el: Element) => boolean): number {
82
+ let disposed = 0;
83
+ for (const el of root.querySelectorAll<IslandElement>(ISLAND_SELECTOR)) {
84
+ if (kept(el) || el.__x === undefined) continue;
85
+ disposed += 1;
86
+ el.__x.then(
87
+ (dispose) => {
88
+ if (typeof dispose === 'function') dispose();
89
+ },
90
+ () => undefined,
91
+ );
92
+ }
93
+ return disposed;
94
+ }
95
+
96
+ /** `[data-x-persist="id"]` present in BOTH documents: the live element moves into the next body. */
97
+ export function persistedPairs(
98
+ current: ParentNode,
99
+ next: ParentNode,
100
+ ): readonly (readonly [Element, Element])[] {
101
+ const pairs: (readonly [Element, Element])[] = [];
102
+ for (const incoming of next.querySelectorAll(`[${NAVIGATION_PERSIST_ATTRIBUTE}]`)) {
103
+ const id = incoming.getAttribute(NAVIGATION_PERSIST_ATTRIBUTE) ?? '';
104
+ const live = [...current.querySelectorAll(`[${NAVIGATION_PERSIST_ATTRIBUTE}]`)].find(
105
+ (el) => el.getAttribute(NAVIGATION_PERSIST_ATTRIBUTE) === id,
106
+ );
107
+ if (id !== '' && live !== undefined) pairs.push([live, incoming]);
108
+ }
109
+ return pairs;
110
+ }
111
+
112
+ /**
113
+ * Re-creates executable scripts so they run: a script parsed by `DOMParser` is inert wherever it
114
+ * is inserted. A `src` this tab already ran is left alone (the page boot runs once per tab, as it
115
+ * does once per load); an inline one runs again — the hydration runtime, which skips every island
116
+ * it already visited. In document order, each `src` awaited, so a deferred boot still runs before
117
+ * the island modules that follow it. `place` puts the fresh copy where it belongs: in place of the
118
+ * inert one in a body, appended to the head. A `src` is recorded only once it is IN the document.
119
+ */
120
+ export async function runScripts(
121
+ scripts: readonly HTMLScriptElement[],
122
+ ran: Set<string>,
123
+ place: (fresh: HTMLScriptElement, inert: HTMLScriptElement) => void,
124
+ ): Promise<void> {
125
+ for (const inert of scripts) {
126
+ if (!executes(inert)) continue;
127
+ const doc = inert.ownerDocument;
128
+ const src = inert.getAttribute('src');
129
+ const url = src === null ? null : new URL(src, doc.baseURI).href;
130
+ if (url !== null && ran.has(url)) continue;
131
+ const fresh = doc.createElement('script');
132
+ for (const attr of inert.attributes) fresh.setAttribute(attr.name, attr.value);
133
+ // `nonce` is hidden from the attribute once parsed; the property still carries it.
134
+ if (inert.nonce) fresh.nonce = inert.nonce;
135
+ fresh.textContent = inert.textContent;
136
+ if (url === null) {
137
+ place(fresh, inert);
138
+ continue;
139
+ }
140
+ fresh.async = false;
141
+ await new Promise<void>((done) => {
142
+ fresh.onload = () => done();
143
+ fresh.onerror = () => done();
144
+ place(fresh, inert);
145
+ ran.add(url);
146
+ });
147
+ }
148
+ }
149
+
150
+ /**
151
+ * The attributes a server document put on each persisted root: taken when the router starts (before
152
+ * any island or app script has run) and after every sync — the only ones a later page may remove.
153
+ */
154
+ const serverAttributes = new WeakMap<Element, ReadonlySet<string>>();
155
+
156
+ /** Snapshot the document's persisted roots; call once, when the router starts. */
157
+ export function notePersisted(doc: Document): void {
158
+ for (const el of doc.querySelectorAll(`[${NAVIGATION_PERSIST_ATTRIBUTE}]`)) {
159
+ serverAttributes.set(el, new Set(el.getAttributeNames()));
160
+ }
161
+ }
162
+
163
+ /** Attributes the hydration runtime sets on an island root: the live element's, never a page's. */
164
+ const RUNTIME_ATTRIBUTES: ReadonlySet<string> = new Set(['data-x-mounted', 'data-x-failed']);
165
+
166
+ /** Where `el` (under `from`) sits under `to`: by id, else by the same child path and tag. */
167
+ function counterpart(el: Element, from: Element, to: Element): Element | null {
168
+ if (el.id !== '') {
169
+ const byId = [...to.querySelectorAll('[id]')].find((other) => other.id === el.id);
170
+ if (byId !== undefined) return byId;
171
+ }
172
+ const path: number[] = [];
173
+ for (let at: Element = el; at !== from; ) {
174
+ const parent: Element | null = at.parentElement;
175
+ if (parent === null) return null;
176
+ path.unshift([...parent.children].indexOf(at));
177
+ at = parent;
178
+ }
179
+ let walked: Element | undefined = to;
180
+ for (const index of path) walked = walked?.children[index];
181
+ return walked !== undefined && walked.tagName === el.tagName ? walked : null;
182
+ }
183
+
184
+ /**
185
+ * A persisted element keeps its node, its state and its islands — and takes the incoming page's
186
+ * word for what it SAYS about the page: the root's attributes, and every descendant's
187
+ * `aria-current` with the `class` that styles it (the sidebar's active item moves).
188
+ */
189
+ export function syncPersisted(kept: Element, incoming: Element): void {
190
+ // Only what a server document said may be unsaid: an attribute a script set at run time (a
191
+ // collapsed state, an island's marker) is the tab's, and survives like the node it sits on.
192
+ const said = serverAttributes.get(kept) ?? new Set(kept.getAttributeNames());
193
+ for (const name of said) {
194
+ if (!incoming.hasAttribute(name) && !RUNTIME_ATTRIBUTES.has(name)) kept.removeAttribute(name);
195
+ }
196
+ for (const attr of incoming.attributes) kept.setAttribute(attr.name, attr.value);
197
+ serverAttributes.set(kept, new Set(incoming.getAttributeNames()));
198
+ const pairs = new Map<Element, Element | null>();
199
+ for (const el of kept.querySelectorAll('[aria-current]')) {
200
+ pairs.set(el, counterpart(el, kept, incoming));
201
+ }
202
+ for (const el of incoming.querySelectorAll('[aria-current]')) {
203
+ const live = counterpart(el, incoming, kept);
204
+ if (live !== null) pairs.set(live, el);
205
+ }
206
+ for (const [live, next] of pairs) {
207
+ const current = next?.getAttribute('aria-current') ?? null;
208
+ if (current === null) live.removeAttribute('aria-current');
209
+ else live.setAttribute('aria-current', current);
210
+ const cls = next?.getAttribute('class') ?? null;
211
+ if (next !== null && next !== undefined) {
212
+ if (cls === null) live.removeAttribute('class');
213
+ else live.setAttribute('class', cls);
214
+ }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * The head elements a server DOCUMENT put there — the only ones a swap may take away. Anything a
220
+ * script added at run time (a CSS-in-JS `<style>`, an analytics tag, a persisted island's own
221
+ * sheet) is the tab's, not the page's, and outlives every navigation as it would have outlived the
222
+ * page's own scripts.
223
+ */
224
+ export function documentHead(doc: Document): WeakSet<Element> {
225
+ return new WeakSet(doc.head.children);
226
+ }
227
+
228
+ /**
229
+ * The swap itself, after the caller decided the answer IS a page for this tab. Synchronous from
230
+ * the dispose to the head, so a view transition's "after" snapshot is one consistent document.
231
+ * `owned` is `documentHead`'s set, kept by the caller across swaps and grown by each one.
232
+ */
233
+ export function swapDocument(doc: Document, next: Document, owned: WeakSet<Element>): Swapped {
234
+ // Read before the head moves: `next.title` reads the `<title>` element, which the plan adopts.
235
+ const title = next.title;
236
+ const nextSheets = new Set(
237
+ [...next.head.querySelectorAll('link')]
238
+ .filter(isStylesheet)
239
+ .map((link) => new URL(link.getAttribute('href') ?? '', doc.baseURI).href),
240
+ );
241
+ // Inline head scripts by TEXT: one this page already ran (the theme boot, on every document)
242
+ // is not run again; one only the next page has runs once; one only this page had is dropped.
243
+ const inline = (head: HTMLHeadElement): HTMLScriptElement[] =>
244
+ [...head.querySelectorAll('script')].filter((s) => executes(s) && !s.hasAttribute('src'));
245
+ const ranInline = new Set(inline(doc.head).map((s) => s.textContent));
246
+ const nextInline = new Set(inline(next.head).map((s) => s.textContent));
247
+ const headScripts = [...next.head.querySelectorAll('script')].filter(
248
+ (script) =>
249
+ executes(script) && (script.hasAttribute('src') || !ranInline.has(script.textContent)),
250
+ );
251
+ for (const stale of inline(doc.head)) {
252
+ if (owned.has(stale) && !nextInline.has(stale.textContent)) stale.remove();
253
+ }
254
+ const pairs = persistedPairs(doc.body, next.body);
255
+ const live = pairs.map(([el]) => el);
256
+ disposeIslands(doc.body, (island) => live.some((kept) => kept.contains(island)));
257
+ for (const [kept, incoming] of pairs) {
258
+ syncPersisted(kept, incoming);
259
+ incoming.replaceWith(kept);
260
+ }
261
+ const body = doc.adoptNode(next.body);
262
+ doc.documentElement.replaceChild(body, doc.body);
263
+ const plan = headPlan(
264
+ [...doc.head.children].filter((el) => owned.has(el)),
265
+ [...next.head.children],
266
+ );
267
+ for (const el of plan.remove) el.remove();
268
+ for (const el of plan.add) {
269
+ doc.head.append(doc.adoptNode(el));
270
+ owned.add(el);
271
+ }
272
+ // Retired only now, after the next page's sheets loaded: the previous page's own, never a
273
+ // sheet a script added (not `owned`), never one both pages link.
274
+ for (const sheet of [...doc.head.querySelectorAll('link')].filter(isStylesheet)) {
275
+ const href = new URL(sheet.getAttribute('href') ?? '', doc.baseURI).href;
276
+ if (owned.has(sheet) && !nextSheets.has(href)) sheet.remove();
277
+ }
278
+ for (const sheet of [...doc.head.querySelectorAll('link')].filter(isStylesheet)) {
279
+ if (nextSheets.has(new URL(sheet.getAttribute('href') ?? '', doc.baseURI).href))
280
+ owned.add(sheet);
281
+ }
282
+ // `lang` and `dir` are the document's; `data-theme` and the rest belong to this tab's scripts.
283
+ for (const name of ['lang', 'dir']) {
284
+ const value = next.documentElement.getAttribute(name);
285
+ if (value === null) doc.documentElement.removeAttribute(name);
286
+ else doc.documentElement.setAttribute(name, value);
287
+ }
288
+ doc.title = title;
289
+ return { body, headScripts };
290
+ }
291
+
292
+ /** What the caller still owes the new document once the view transition has painted it. */
293
+ export interface Swapped {
294
+ readonly body: HTMLElement;
295
+ /** The next page's `<script src>` in its head, still inert — `runScripts` them into the head. */
296
+ readonly headScripts: readonly HTMLScriptElement[];
297
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * What an opted-in document carries for the client router: which surface rendered it, which build
3
+ * (the skew check — the same meta realtime's sync tags write, deduped by key), and the router's
4
+ * script. A surface that did not opt in gets none of the three, so it pays no byte.
5
+ */
6
+
7
+ import { CLIENT_BUILD_META } from '@ultimat3/core';
8
+ import type { HeadTag } from './head';
9
+ import { NAVIGATION_META } from './navigation-rules';
10
+
11
+ export interface ClientNavigationHead {
12
+ /** The surface this document belongs to — a link that lands on another is a full load. */
13
+ readonly surface: string;
14
+ readonly buildId: string;
15
+ /** `/_x/navigation/<hash>.js`, content-addressed and served `immutable`. */
16
+ readonly scriptUrl: string;
17
+ }
18
+
19
+ export function clientNavigationTags(head: ClientNavigationHead): readonly HeadTag[] {
20
+ return [
21
+ {
22
+ kind: 'meta',
23
+ key: `meta:${NAVIGATION_META}`,
24
+ attrs: { name: NAVIGATION_META, content: head.surface },
25
+ },
26
+ {
27
+ kind: 'meta',
28
+ key: `meta:${CLIENT_BUILD_META}`,
29
+ attrs: { name: CLIENT_BUILD_META, content: head.buildId },
30
+ },
31
+ // Deferred, in `<head>`: it runs once, after parsing, and survives every swap of the body.
32
+ {
33
+ kind: 'script',
34
+ key: 'script:ultimate-navigation',
35
+ attrs: { src: head.scriptUrl, defer: true },
36
+ },
37
+ ];
38
+ }