@brust/runtime-dom 2.0.0-alpha.24 → 2.0.0-alpha.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/directives/bind.ts +17 -0
- package/src/directives/common.ts +1 -0
- package/src/directives/model.ts +4 -3
- package/src/directives/on.ts +5 -1
- package/src/island.ts +22 -6
- package/src/mount.ts +1 -1
- package/src/nav/chain.ts +57 -0
- package/src/nav/index.ts +5 -0
- package/src/nav/nav.ts +191 -0
- package/src/nav/swap.ts +148 -0
package/package.json
CHANGED
package/src/directives/bind.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { reactEvent } from './on'
|
|
1
2
|
import { BINDERS, parsed, read, warnOnce } from '../core-api'
|
|
2
3
|
import { BOOLEAN, OVERLOADED, STRINGIFY } from '../attr-kinds'
|
|
3
4
|
import type { Instance } from '../instance'
|
|
@@ -11,6 +12,8 @@ function writeAttr(inst: Instance, attr: string, el: Element, name: string, v: u
|
|
|
11
12
|
// setAttribute: `className` is a getter-only SVGAnimatedString on SVG elements (F96a).
|
|
12
13
|
// F311: undefined/null removes `class` (React does; `className=""` keeps an empty one), and an empty `style` is no attribute (the first paint omits it)
|
|
13
14
|
if (name === 'class') { if (v == null) el.removeAttribute('class'); else el.setAttribute('class', `${v}`); return }
|
|
15
|
+
// F434: a multiple select is bound to an array, one pick per option (`null`/`undefined` leave the picks alone, as React does)
|
|
16
|
+
if (name === 'value' && el instanceof HTMLSelectElement && el.multiple) { if (v != null) { const vs = ([] as unknown[]).concat(v).map(String); for (const o of el.options) o.selected = vs.includes(o.value) } return }
|
|
14
17
|
if (name === 'value' && 'value' in el) { (el as HTMLInputElement).value = v == null ? '' : String(v); return }
|
|
15
18
|
// React passes every attribute of a custom element (a tag with `-`) through as given: `true` is ``, `false` omits it.
|
|
16
19
|
const custom = el.localName.includes('-')
|
|
@@ -54,6 +57,20 @@ BINDERS.push({
|
|
|
54
57
|
match: (a) => a.startsWith('x-prop-'),
|
|
55
58
|
bind: ({ inst, el, attr, raw }) => {
|
|
56
59
|
const name = attr.slice('x-prop-'.length)
|
|
60
|
+
// F369c: `x-prop-on-<event>="<prop key>"` calls the instance's prop (a function a caller handed through the child's rest)
|
|
61
|
+
// with the event, reading it at call time so a prop that changes is the one that runs. The event is `[a-z0-9]+`.
|
|
62
|
+
if (name.startsWith('on-')) {
|
|
63
|
+
const event = name.slice(3)
|
|
64
|
+
if (!raw || !/^[a-z0-9]+$/.test(event)) { warnOnce(`pb:${inst.name}:${name}`, `${attr} in ${inst.name} refused`); return }
|
|
65
|
+
const handler = (e: Event) => {
|
|
66
|
+
reactEvent(e)
|
|
67
|
+
const ps = inst.props(); const fn = Object.hasOwn(ps, raw) ? ps[raw] : undefined
|
|
68
|
+
if (typeof fn === 'function') (fn as (e: Event) => void)(e)
|
|
69
|
+
}
|
|
70
|
+
el.addEventListener(event, handler)
|
|
71
|
+
inst.onCleanup(() => el.removeEventListener(event, handler))
|
|
72
|
+
return
|
|
73
|
+
}
|
|
57
74
|
if (!name || !raw || name.startsWith('on') || name.startsWith('x-') || PROP_REFUSED.has(name)) {
|
|
58
75
|
warnOnce(`pb:${inst.name}:${name}`, `${attr} in ${inst.name} refused`); return
|
|
59
76
|
}
|
package/src/directives/common.ts
CHANGED
|
@@ -23,6 +23,7 @@ export function member(inst: Instance, p: ParsedValue, attr: string): unknown {
|
|
|
23
23
|
}
|
|
24
24
|
export function mismatch(inst: Instance, attr: string, _el: Element, server: unknown, client: unknown): void {
|
|
25
25
|
if (inst.booted) return // element created after mount (new x-for row / x-if clone): nothing was server-rendered
|
|
26
|
+
if (inst.late) return // F301: mounted after the page booted (a navigation's markup, a React client mount): no first paint to compare
|
|
26
27
|
if (inst.reseeded) return // F277: a host created later, started from its parent's current props, not the printed ones
|
|
27
28
|
warnOnce(`x:${inst.name}:${attr}`, `first-paint mismatch ${attr} in ${inst.name}: ${JSON.stringify(server)} vs ${JSON.stringify(client)}`)
|
|
28
29
|
}
|
package/src/directives/model.ts
CHANGED
|
@@ -17,9 +17,10 @@ BINDERS.push({
|
|
|
17
17
|
const h = () => { if (input.checked) s.set(input.value) }; el.addEventListener('change', h); inst.onCleanup(() => el.removeEventListener('change', h))
|
|
18
18
|
inst.effect(() => { input.checked = s() === input.value })
|
|
19
19
|
} else if (el instanceof HTMLSelectElement) {
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const
|
|
20
|
+
// F434: a multiple select is an array of the picked values
|
|
21
|
+
const many = el.multiple
|
|
22
|
+
const h = () => s.set(many ? Array.from(el.options).filter((o) => o.selected).map((o) => o.value) : el.value); el.addEventListener('change', h); inst.onCleanup(() => el.removeEventListener('change', h))
|
|
23
|
+
const apply = () => { const v = s(); if (many) { if (v != null) { const vs = ([] as unknown[]).concat(v).map(String); for (const o of el.options) o.selected = vs.includes(o.value) } } else if (el.value !== String(v)) el.value = String(v ?? '') }
|
|
23
24
|
inst.effect(apply)
|
|
24
25
|
const mo = new MutationObserver(() => apply()); mo.observe(el, { childList: true }); inst.onCleanup(() => mo.disconnect())
|
|
25
26
|
} else {
|
package/src/directives/on.ts
CHANGED
|
@@ -10,13 +10,17 @@ const SYNTHETIC: PropertyDescriptorMap = {
|
|
|
10
10
|
isPersistent: { value: () => true },
|
|
11
11
|
nativeEvent: { get(this: Event) { return this } },
|
|
12
12
|
}
|
|
13
|
+
/** Gives a DOM event the React members (once). Shared with `x-prop-on-*`. */
|
|
14
|
+
export function reactEvent(e: Event): void {
|
|
15
|
+
if (!('isPropagationStopped' in e)) Object.defineProperties(e, SYNTHETIC)
|
|
16
|
+
}
|
|
13
17
|
BINDERS.push({
|
|
14
18
|
match: (a) => a.startsWith('x-on-'),
|
|
15
19
|
bind: ({ inst, el, attr, raw, scope }) => {
|
|
16
20
|
const event = attr.slice('x-on-'.length)
|
|
17
21
|
const p = parsed(inst, attr, raw); if (!p) return
|
|
18
22
|
const handler = (e: Event) => {
|
|
19
|
-
|
|
23
|
+
reactEvent(e)
|
|
20
24
|
const fn = member(inst, p, attr)
|
|
21
25
|
if (typeof fn !== 'function') return
|
|
22
26
|
const args = p.bindings.map((b) => scope[b])
|
package/src/island.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// hydrates each host once, when idle (spec S12). Load order between runtime and chunks is free.
|
|
4
4
|
import { warnOnce, insideRaw } from './core-api'
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
/** Hydrates `host`. It may return a dispose function (the React shim returns `() => root.unmount()`): the runtime
|
|
7
|
+
* calls it once, when the host leaves the document, so effects clean up like a React route change. */
|
|
8
|
+
export type IslandHydrate = (host: HTMLElement, props: Record<string, unknown>) => void | (() => void)
|
|
7
9
|
type QueueEntry = [string, IslandHydrate]
|
|
8
10
|
declare global { var __brustIslands: QueueEntry[] | undefined; var __brustIslandReady: (() => void) | undefined }
|
|
9
11
|
|
|
@@ -45,6 +47,8 @@ let idle: Idle = defaultIdle
|
|
|
45
47
|
export function _setIdle(fn: Idle | null): void { idle = fn ?? defaultIdle }
|
|
46
48
|
|
|
47
49
|
const pending = new Set<HTMLElement>()
|
|
50
|
+
/** Dispose functions of hydrated hosts, until the host leaves the document. */
|
|
51
|
+
const live = new Map<HTMLElement, () => void>()
|
|
48
52
|
|
|
49
53
|
function readIslandProps(host: HTMLElement, id: string): Record<string, unknown> {
|
|
50
54
|
const raw = host.getAttribute('x-props')
|
|
@@ -56,7 +60,11 @@ function readIslandProps(host: HTMLElement, id: string): Record<string, unknown>
|
|
|
56
60
|
function hydrateHost(host: HTMLElement, id: string, hydrate: IslandHydrate): void {
|
|
57
61
|
pending.delete(host)
|
|
58
62
|
if (!host.isConnected || host.hasAttribute('data-hydrated')) return
|
|
59
|
-
try {
|
|
63
|
+
try {
|
|
64
|
+
const dispose = hydrate(host, readIslandProps(host, id))
|
|
65
|
+
if (typeof dispose === 'function') live.set(host, dispose)
|
|
66
|
+
host.setAttribute('data-hydrated', '1')
|
|
67
|
+
}
|
|
60
68
|
catch (e) { console.error(`[brust] hydrate threw: ${id}`, e) }
|
|
61
69
|
}
|
|
62
70
|
|
|
@@ -74,16 +82,24 @@ export function scheduleIslands(root: ParentNode): void {
|
|
|
74
82
|
root.querySelectorAll<HTMLElement>('brust-island[data-id]').forEach(scheduleHost)
|
|
75
83
|
}
|
|
76
84
|
|
|
77
|
-
/**
|
|
85
|
+
/** A subtree left the document: forget its pending hosts and unmount the roots of its hydrated ones. */
|
|
78
86
|
export function cancelIslands(root: Node): void {
|
|
79
87
|
if (!(root instanceof Element)) return
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
const hosts = root.querySelectorAll<HTMLElement>('brust-island[data-id]')
|
|
89
|
+
const forget = (h: HTMLElement) => {
|
|
90
|
+
pending.delete(h)
|
|
91
|
+
const dispose = live.get(h)
|
|
92
|
+
if (!dispose) return
|
|
93
|
+
live.delete(h)
|
|
94
|
+
try { dispose() } catch (e) { console.error('[brust] island unmount threw', e) }
|
|
95
|
+
}
|
|
96
|
+
if (root instanceof HTMLElement) forget(root)
|
|
97
|
+
hosts.forEach(forget)
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
/** Test helper: forget registrations, waiters, pending hosts and the global queue. */
|
|
85
101
|
export function _resetIslands(): void {
|
|
86
|
-
slots.clear(); pending.clear(); idle = defaultIdle
|
|
102
|
+
slots.clear(); pending.clear(); live.clear(); idle = defaultIdle
|
|
87
103
|
globalThis.__brustIslands = []
|
|
88
104
|
globalThis.__brustIslandReady = undefined
|
|
89
105
|
}
|
package/src/mount.ts
CHANGED
|
@@ -93,6 +93,6 @@ function startObserver(root: ParentNode): void {
|
|
|
93
93
|
observer.observe(root, { childList: true, subtree: true })
|
|
94
94
|
}
|
|
95
95
|
export function unmount(root: ParentNode = document.body): void {
|
|
96
|
-
if (root === observedRoot) { observer?.disconnect(); observer = null; observedRoot = null }
|
|
96
|
+
if (root === observedRoot) { observer?.disconnect(); observer = null; observedRoot = null; pageBooted = false } // torn down: the next mount() is a fresh boot
|
|
97
97
|
disposeTree(root as Node)
|
|
98
98
|
}
|
package/src/nav/chain.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The layout chain of a brust document (design §5.9): the root level from the nav tag's `data-brust-root`, then the
|
|
2
|
+
// `<!--brust-l:<id>:<hash>-->…<!--/brust-l:<id>-->` pairs the server wraps around every inner level, outermost first.
|
|
3
|
+
|
|
4
|
+
/** One level: `key` is `<id>:<shellHash>`; an inner level has its two comments. */
|
|
5
|
+
export interface Level { key: string; open?: Comment; close?: Comment }
|
|
6
|
+
|
|
7
|
+
/** null when the document has no usable chain (no root, unbalanced, crossing or sibling pairs): swap the whole body. */
|
|
8
|
+
export function readChain(doc: Document): Level[] | null {
|
|
9
|
+
const root = doc.querySelector('script[data-brust-nav]')?.getAttribute('data-brust-root')
|
|
10
|
+
if (!root || !doc.body) return null
|
|
11
|
+
const out: Level[] = [{ key: root }]
|
|
12
|
+
const open: Level[] = []
|
|
13
|
+
const w = doc.createTreeWalker(doc.body, 128 /* NodeFilter.SHOW_COMMENT */)
|
|
14
|
+
for (let n: Node | null; (n = w.nextNode()); ) {
|
|
15
|
+
const v = (n as Comment).data
|
|
16
|
+
if (v.startsWith('brust-l:')) {
|
|
17
|
+
// a level opens inside the deepest open one, never beside a closed one: the chain is a line
|
|
18
|
+
if (open.length !== out.length - 1) return null
|
|
19
|
+
const l: Level = { key: v.slice(8), open: n as Comment }
|
|
20
|
+
out.push(l)
|
|
21
|
+
open.push(l)
|
|
22
|
+
} else if (v.startsWith('/brust-l:')) {
|
|
23
|
+
const l = open.pop()
|
|
24
|
+
if (!l || l.key.slice(0, l.key.lastIndexOf(':')) !== v.slice(9)) return null
|
|
25
|
+
l.close = n as Comment
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return open.length ? null : out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A pair can be a range only when the parser kept its two comments siblings (a block in a `<p>` splits them), and not
|
|
33
|
+
* in a table section: there the comments stay put while the parser moves the content out in front of the table
|
|
34
|
+
* (foster parenting), so the range would hold nothing of the level.
|
|
35
|
+
*/
|
|
36
|
+
const usable = (l: Level) => l.open!.parentNode === l.close!.parentNode && !/^T(ABLE|BODY|HEAD|FOOT|R)$/.test(l.open!.parentNode!.nodeName)
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The level to replace: the first one whose id or hash differs, else the page (never kept), moved up while its pair is
|
|
40
|
+
* not a sibling pair in either document. 0 = the whole body.
|
|
41
|
+
*/
|
|
42
|
+
export function swapLevel(a: Level[] | null, b: Level[] | null): number {
|
|
43
|
+
if (!a || !b || a[0]!.key !== b[0]!.key) return 0
|
|
44
|
+
const leaf = Math.min(a.length, b.length) - 1
|
|
45
|
+
let i = 1
|
|
46
|
+
while (i < leaf && a[i]!.key === b[i]!.key) i++
|
|
47
|
+
if (i > leaf) return 0
|
|
48
|
+
while (i && !(usable(a[i]!) && usable(b[i]!))) i--
|
|
49
|
+
return i
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The nodes of a level's range, both comments included. */
|
|
53
|
+
export function between(l: Level): ChildNode[] {
|
|
54
|
+
const out: ChildNode[] = []
|
|
55
|
+
for (let n: ChildNode | null = l.open!; n; n = n === l.close ? null : n.nextSibling) out.push(n)
|
|
56
|
+
return out
|
|
57
|
+
}
|
package/src/nav/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The `nav` asset (design §5.8): SPA navigation on every full brust document, also on a page that loads no runtime.
|
|
2
|
+
// Standalone: nothing under nav/ imports the runtime core. `navigate()` reaches it through `Symbol.for('brust.nav')`.
|
|
3
|
+
import { boot } from './nav'
|
|
4
|
+
|
|
5
|
+
boot()
|
package/src/nav/nav.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// SPA navigation (design docs/design/2026-10-25-spa-navigation-design.md): a qualifying link click, `navigate()` or
|
|
2
|
+
// back/forward fetches the real HTML document and swaps it in place, so the page shows exactly what a full load of
|
|
3
|
+
// that URL shows (§2). Anything that cannot be swapped is a full load. Events: `brust:nav:click|load|success|error`.
|
|
4
|
+
import { applyHead, classicScripts, planHead, runScripts, swapBody } from './swap'
|
|
5
|
+
|
|
6
|
+
type Trigger = 'link' | 'navigate' | 'popstate'
|
|
7
|
+
export interface NavBridge { navigate(url: string, opts?: { replace?: boolean }): Promise<void> }
|
|
8
|
+
/** How a module script of the new page is loaded; tests swap it. */
|
|
9
|
+
export type Load = (url: string) => Promise<unknown>
|
|
10
|
+
|
|
11
|
+
const KEY = Symbol.for('brust.nav')
|
|
12
|
+
const LOADING = 'data-brust-loading'
|
|
13
|
+
const NAV = 'script[data-brust-nav]'
|
|
14
|
+
const sameDoc = (a: string, b: string) => a.split('#')[0] === b.split('#')[0]
|
|
15
|
+
|
|
16
|
+
/** Boot once per tab. Returns a stop function (tests). */
|
|
17
|
+
export function boot(load: Load = (u) => import(/* @vite-ignore */ u)): () => void {
|
|
18
|
+
const g = globalThis as { [KEY]?: NavBridge }
|
|
19
|
+
const build = document.querySelector(NAV)?.getAttribute('data-brust-build')
|
|
20
|
+
if (g[KEY] || build == null) return () => {}
|
|
21
|
+
const off = new AbortController()
|
|
22
|
+
const on = (t: EventTarget, type: string, h: (e: any) => void, capture?: boolean) =>
|
|
23
|
+
t.addEventListener(type, h, { capture, passive: type === 'touchstart', signal: off.signal })
|
|
24
|
+
const html = document.documentElement
|
|
25
|
+
const fire = (name: string, detail: object, cancelable?: boolean) =>
|
|
26
|
+
document.dispatchEvent(new CustomEvent('brust:nav:' + name, { detail, bubbles: true, cancelable }))
|
|
27
|
+
|
|
28
|
+
// ---- history entries: an id per entry in history.state.brust, scroll positions in memory (§5.7) ----
|
|
29
|
+
const stamp = Date.now().toString(36)
|
|
30
|
+
let n = 0
|
|
31
|
+
let seq = 0
|
|
32
|
+
const pos = new Map<string, [number, number]>()
|
|
33
|
+
const tag = (replaceUrl?: string) => {
|
|
34
|
+
const id = stamp + n++
|
|
35
|
+
history.replaceState({ ...history.state, brust: id }, '', replaceUrl)
|
|
36
|
+
return id
|
|
37
|
+
}
|
|
38
|
+
let cur: string = history.state?.brust ?? tag()
|
|
39
|
+
let shown = location.href
|
|
40
|
+
history.scrollRestoration = 'manual'
|
|
41
|
+
const land = (url: string, at?: [number, number]) => {
|
|
42
|
+
if (at) return scrollTo(at[0], at[1])
|
|
43
|
+
let t: Element | null = null
|
|
44
|
+
try { const h = decodeURIComponent(new URL(url).hash.slice(1)); t = h ? document.getElementById(h) : null } catch {}
|
|
45
|
+
t ? t.scrollIntoView() : scrollTo(0, 0)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- prefetch on intent (§5.2): one response per URL, held 15 s, used once ----
|
|
49
|
+
const held = new Map<string, [Promise<Response>, number]>()
|
|
50
|
+
const take = (u: string) => {
|
|
51
|
+
const e = held.get(u)
|
|
52
|
+
held.delete(u)
|
|
53
|
+
return e && e[1] > Date.now() ? e[0] : undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The URL a link navigates to, or null when the click (or a prefetch) is left to the browser (§5.1). */
|
|
57
|
+
const target = (t: EventTarget | null): [Element, string] | null => {
|
|
58
|
+
const a = (t as Element | null)?.closest?.('a')
|
|
59
|
+
const href = a?.getAttribute('href')
|
|
60
|
+
if (href == null || a!.hasAttribute('download') || (a!.getAttribute('target') || '_self').toLowerCase() !== '_self' || a!.closest('[data-brust-reload]')) return null
|
|
61
|
+
let u: URL
|
|
62
|
+
try { u = new URL(href, document.baseURI) } catch { return null }
|
|
63
|
+
if (u.origin !== location.origin || !/^https?:$/.test(u.protocol) || (u.href.includes('#') && sameDoc(u.href, location.href))) return null
|
|
64
|
+
return [a!, u.href]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const prefetch = (t: EventTarget | null) => {
|
|
68
|
+
const l = target(t)
|
|
69
|
+
const c = (navigator as { connection?: { saveData?: boolean; effectiveType?: string } }).connection
|
|
70
|
+
if (!l || l[0].closest('[data-brust-prefetch="false"]') || c?.saveData || /2g/.test(c?.effectiveType ?? '')) return
|
|
71
|
+
const u = l[1]
|
|
72
|
+
if ((held.get(u)?.[1] ?? 0) > Date.now()) return
|
|
73
|
+
const p = fetch(u, { priority: 'low' } as RequestInit)
|
|
74
|
+
p.catch(() => {})
|
|
75
|
+
held.set(u, [p, Date.now() + 15e3])
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---- one navigation (§3); a newer one aborts it and it fires nothing more ----
|
|
79
|
+
let active: AbortController | null = null
|
|
80
|
+
const go = async (req: string, trigger: Trigger, id: number, replace: boolean, pop?: string): Promise<void> => {
|
|
81
|
+
active?.abort()
|
|
82
|
+
const c = (active = new AbortController())
|
|
83
|
+
const live = () => c === active
|
|
84
|
+
const from = shown
|
|
85
|
+
let url = req
|
|
86
|
+
let status = 0
|
|
87
|
+
let pushed = false
|
|
88
|
+
const added: Element[] = []
|
|
89
|
+
html.setAttribute(LOADING, '')
|
|
90
|
+
fire('load', { id, url, from, trigger })
|
|
91
|
+
try {
|
|
92
|
+
const res = await (take(req) ?? fetch(req, { signal: c.signal }))
|
|
93
|
+
if (!live()) return
|
|
94
|
+
status = res.status
|
|
95
|
+
// the final URL (after redirects); a fetch drops the fragment, which a redirect without one keeps
|
|
96
|
+
const fin = new URL(res.url || req)
|
|
97
|
+
// a redirect that left this origin (an open redirect to a CORS-readable page) is never parsed: a full load
|
|
98
|
+
if (fin.origin !== location.origin) throw new Error('cross-origin')
|
|
99
|
+
fin.hash ||= new URL(req).hash
|
|
100
|
+
url = fin.href
|
|
101
|
+
const doc = /html/.test(res.headers.get('content-type') ?? '') ? new DOMParser().parseFromString(await res.text(), 'text/html') : null
|
|
102
|
+
if (!live()) return
|
|
103
|
+
if (doc?.querySelector(NAV)?.getAttribute('data-brust-build') !== build) throw new Error(`not a brust page of this build`)
|
|
104
|
+
|
|
105
|
+
// §3 step 4: everything the new page needs before it is shown. The store tags first, ahead of the old ones (a
|
|
106
|
+
// store seeds from the first tag of its name; a live store never re-reads one), then the module scripts in
|
|
107
|
+
// document order (one at a time: the order they register in is the order the server wrote), then the stylesheets
|
|
108
|
+
// the head plan inserted, with `media="not all"`: loaded and parsed, applied to nothing until the swap.
|
|
109
|
+
const stores = Array.from(doc.querySelectorAll('script[data-brust-store]'), (s) => document.importNode(s, true))
|
|
110
|
+
document.head.append(...stores)
|
|
111
|
+
added.push(...stores)
|
|
112
|
+
for (const s of doc.querySelectorAll('script[type=module][src]')) {
|
|
113
|
+
if (!s.matches(NAV)) await load(new URL(s.getAttribute('src')!, url).href)
|
|
114
|
+
if (!live()) return
|
|
115
|
+
}
|
|
116
|
+
const plan = planHead([...doc.head.children])
|
|
117
|
+
added.push(...plan.sheets.map(([e]) => e))
|
|
118
|
+
// a load event is a later task: the handlers set now still catch a cached sheet
|
|
119
|
+
await Promise.all(plan.sheets.map(([e]) => new Promise((r) => { (e as HTMLLinkElement).onload = (e as HTMLLinkElement).onerror = r })))
|
|
120
|
+
if (!live()) return
|
|
121
|
+
|
|
122
|
+
// §3 step 5: swap (one task, nothing painted in between), then history and scroll
|
|
123
|
+
for (const [e, m] of plan.sheets) m == null ? e.removeAttribute('media') : e.setAttribute('media', m)
|
|
124
|
+
pos.set(cur, [scrollX, scrollY])
|
|
125
|
+
const fresh = applyHead(plan)
|
|
126
|
+
const [body, whole] = swapBody(doc, LOADING)
|
|
127
|
+
added.length = 0
|
|
128
|
+
if (trigger === 'popstate') cur = pop || tag()
|
|
129
|
+
else if (replace) history.replaceState({ ...history.state, brust: cur }, '', url)
|
|
130
|
+
else history.pushState({ brust: (cur = stamp + n++) }, '', url)
|
|
131
|
+
pushed = true
|
|
132
|
+
shown = location.href
|
|
133
|
+
land(url, pos.get(pop!))
|
|
134
|
+
// the head scripts new to it run; after a whole-body swap (<html> and <body> attributes replaced) every one runs
|
|
135
|
+
// again, as on a full load (a theme script setting <html class>), but a data-brust-once one only if it is new
|
|
136
|
+
const head = whole ? [...document.head.children].filter((e) => !e.hasAttribute('data-brust-once') || fresh.includes(e)) : fresh
|
|
137
|
+
await runScripts([...classicScripts(head), ...body])
|
|
138
|
+
if (!live()) return
|
|
139
|
+
html.removeAttribute(LOADING)
|
|
140
|
+
if (status < 400) fire('success', { id, url, from, trigger, status })
|
|
141
|
+
else fire('error', { id, url, from, trigger, status, error: undefined, swapped: true })
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (!live()) return
|
|
144
|
+
html.removeAttribute(LOADING)
|
|
145
|
+
// nothing could be swapped: the default action is a full load of the URL (cancelable)
|
|
146
|
+
if (fire('error', { id, url, from, trigger, status, error, swapped: false }, true)) location[pushed || trigger === 'popstate' ? 'replace' : 'assign'](url)
|
|
147
|
+
} finally {
|
|
148
|
+
for (const e of added) e.remove()
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const navigate = (u: string, o?: { replace?: boolean }): Promise<void> => {
|
|
153
|
+
const url = new URL(u, location.href).href
|
|
154
|
+
if (url.startsWith(location.origin + '/') && !document.body.closest('[data-brust-reload]') && !(url.includes('#') && sameDoc(url, location.href)))
|
|
155
|
+
return go(url, 'navigate', ++seq, !!o?.replace || sameDoc(url, location.href))
|
|
156
|
+
location[o?.replace ? 'replace' : 'assign'](url)
|
|
157
|
+
return Promise.resolve()
|
|
158
|
+
}
|
|
159
|
+
g[KEY] = { navigate }
|
|
160
|
+
|
|
161
|
+
on(document, 'click', (e: MouseEvent) => {
|
|
162
|
+
if (e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
163
|
+
const l = target(e.target)
|
|
164
|
+
if (!l) return
|
|
165
|
+
const id = ++seq
|
|
166
|
+
if (!fire('click', { id, url: l[1], from: shown, link: l[0] }, true)) return
|
|
167
|
+
e.preventDefault()
|
|
168
|
+
void go(l[1], 'link', id, sameDoc(l[1], location.href))
|
|
169
|
+
})
|
|
170
|
+
let hover: ReturnType<typeof setTimeout> | undefined
|
|
171
|
+
const isA = (t: EventTarget | null) => (t as Element | null)?.localName === 'a'
|
|
172
|
+
on(document, 'pointerenter', (e) => { if (isA(e.target)) hover = setTimeout(() => prefetch(e.target), 65) }, true)
|
|
173
|
+
on(document, 'pointerleave', (e) => { if (isA(e.target)) clearTimeout(hover) }, true)
|
|
174
|
+
on(document, 'focusin', (e) => prefetch(e.target))
|
|
175
|
+
on(document, 'touchstart', (e) => prefetch(e.target), true)
|
|
176
|
+
on(window, 'popstate', (e: PopStateEvent) => {
|
|
177
|
+
const to: string | undefined = e.state?.brust
|
|
178
|
+
if (sameDoc(location.href, shown)) {
|
|
179
|
+
// a fragment entry of the page on screen: the browser owns it; keep the scroll of the entry left
|
|
180
|
+
pos.set(cur, [scrollX, scrollY])
|
|
181
|
+
cur = to || tag()
|
|
182
|
+
shown = location.href
|
|
183
|
+
return land(shown, pos.get(cur))
|
|
184
|
+
}
|
|
185
|
+
void go(location.href, 'popstate', ++seq, false, to)
|
|
186
|
+
})
|
|
187
|
+
return () => {
|
|
188
|
+
off.abort()
|
|
189
|
+
delete g[KEY]
|
|
190
|
+
}
|
|
191
|
+
}
|
package/src/nav/swap.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// The DOM half of a navigation (design §3 step 5): the head made equal to the new head, then the body swapped whole or
|
|
2
|
+
// from one layout level down (§5.9), persisted elements moved over, and the new classic scripts run.
|
|
3
|
+
import { between, readChain, swapLevel } from './chain'
|
|
4
|
+
|
|
5
|
+
/** `sheets`: the inserted stylesheets with their own `media` (null: none); they carry `media="not all"` until the swap. */
|
|
6
|
+
const SHEET = 'link[rel~=stylesheet]'
|
|
7
|
+
|
|
8
|
+
export interface HeadPlan { want: Element[]; fixed: Set<Element>; sheets: [Element, string | null][] }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* §3 step 5, planned before the swap: each new head element is matched to an unused old one with the same outerHTML
|
|
12
|
+
* (kept, never re-created) or is new. The kept elements already in the right order (the longest such run) never move.
|
|
13
|
+
* A new stylesheet, or a kept one out of that run, is inserted now at its final place (a clone for a kept one): a moved
|
|
14
|
+
* `<link>` drops its sheet until a later task, a visible flash, so no loaded stylesheet ever moves. They go in with
|
|
15
|
+
* `media="not all"` (loaded and parsed, applied to nothing) and come back in `sheets` for the caller to await.
|
|
16
|
+
*/
|
|
17
|
+
export function planHead(next: Element[]): HeadPlan {
|
|
18
|
+
const head = document.head
|
|
19
|
+
const old = [...head.children]
|
|
20
|
+
const pool = new Map<string, Element[]>()
|
|
21
|
+
for (const el of old) { const k = el.outerHTML; pool.has(k) ? pool.get(k)!.push(el) : pool.set(k, [el]) }
|
|
22
|
+
const want = next.map((n) => pool.get(n.outerHTML)?.shift() ?? n)
|
|
23
|
+
// the heaviest run of old elements already in order, a stylesheet outweighing everything else (a head is small:
|
|
24
|
+
// quadratic is fine)
|
|
25
|
+
const at = want.map((el) => old.indexOf(el))
|
|
26
|
+
const w: number[] = []
|
|
27
|
+
const prev: number[] = []
|
|
28
|
+
let best = -1
|
|
29
|
+
at.forEach((v, i) => {
|
|
30
|
+
if (v < 0) return
|
|
31
|
+
const own = want[i]!.matches(SHEET) ? 1e3 : 1
|
|
32
|
+
w[i] = own
|
|
33
|
+
prev[i] = -1
|
|
34
|
+
for (let j = 0; j < i; j++) if (at[j]! >= 0 && at[j]! < v && w[j]! + own > w[i]!) { w[i] = w[j]! + own; prev[i] = j }
|
|
35
|
+
if (best < 0 || w[i]! > w[best]!) best = i
|
|
36
|
+
})
|
|
37
|
+
const fixed = new Set<Element>()
|
|
38
|
+
for (let i = best; i >= 0; i = prev[i]!) fixed.add(want[i]!)
|
|
39
|
+
const sheets: HeadPlan['sheets'] = []
|
|
40
|
+
want.forEach((el, i) => {
|
|
41
|
+
if (fixed.has(el) || !el.matches(SHEET)) return
|
|
42
|
+
const s = el.ownerDocument === document ? (el.cloneNode() as Element) : document.adoptNode(el)
|
|
43
|
+
sheets.push([s, s.getAttribute('media')])
|
|
44
|
+
s.setAttribute('media', 'not all')
|
|
45
|
+
head.insertBefore(s, want.slice(i + 1).find((e) => fixed.has(e)) ?? null)
|
|
46
|
+
fixed.add((want[i] = s))
|
|
47
|
+
})
|
|
48
|
+
return { want, fixed, sheets }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Make the head's element children exactly `plan.want`, moving no fixed element. Returns the elements new to it. */
|
|
52
|
+
export function applyHead({ want, fixed }: HeadPlan): Element[] {
|
|
53
|
+
const head = document.head
|
|
54
|
+
const keep = new Set(want)
|
|
55
|
+
for (const el of [...head.children]) if (!keep.has(el)) el.remove()
|
|
56
|
+
const fresh: Element[] = []
|
|
57
|
+
let prev: Element | null = null
|
|
58
|
+
for (let el of want) {
|
|
59
|
+
if (el.ownerDocument !== document) fresh.push((el = document.adoptNode(el)))
|
|
60
|
+
// the fixed elements are already in order: every other one goes right after the one before it
|
|
61
|
+
if (!fixed.has(el) && (prev ? prev.nextElementSibling : head.firstElementChild) !== el) prev ? prev.after(el) : head.prepend(el)
|
|
62
|
+
prev = el
|
|
63
|
+
}
|
|
64
|
+
return fresh
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function copyAttrs(to: Element, from: Element, keep?: string): void {
|
|
68
|
+
for (const a of [...to.attributes]) if (a.name !== keep && !from.hasAttribute(a.name)) to.removeAttribute(a.name)
|
|
69
|
+
for (const a of from.attributes) to.setAttribute(a.name, a.value)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const all = (nodes: Node[], sel: string) =>
|
|
73
|
+
nodes.flatMap((n) => (n instanceof Element ? [...(n.matches(sel) ? [n] : []), ...n.querySelectorAll(sel)] : []))
|
|
74
|
+
|
|
75
|
+
const P = '[data-brust-persist]'
|
|
76
|
+
const key = (el: Element) => el.getAttribute('data-brust-persist')!
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A live `data-brust-persist` element replaces the inserted element with the same key, in the same task as the swap:
|
|
80
|
+
* it never leaves this document (no adoption into the parsed one), so it is moved, never disposed.
|
|
81
|
+
*/
|
|
82
|
+
function persist(old: Node[]): (next: Node[]) => void {
|
|
83
|
+
const live = new Map(all(old, P).map((el) => [key(el), el]))
|
|
84
|
+
return (next) => { for (const el of all(next, P)) { const k = live.get(key(el)); if (k) el.replaceWith(k) } }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The body-end assets: the nav tag and every node after it (store tags, module scripts). */
|
|
88
|
+
function tail(body: HTMLElement): ChildNode[] | null {
|
|
89
|
+
const tag = body.querySelector(':scope > script[data-brust-nav]')
|
|
90
|
+
const out: ChildNode[] = []
|
|
91
|
+
for (let n: ChildNode | null = tag; n; n = n.nextSibling) out.push(n)
|
|
92
|
+
return tag && out
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Swap the body to `doc`'s (§5.9): from the first level that changed, or the whole body (with the attributes of
|
|
97
|
+
* `<html>` and `<body>`) when the root changed or a chain cannot be trusted. Returns the classic scripts of the new
|
|
98
|
+
* nodes, taken before the persisted elements go in (a live one's scripts already ran: they never run again), and true
|
|
99
|
+
* for a whole-body swap.
|
|
100
|
+
*/
|
|
101
|
+
export function swapBody(doc: Document, loading: string): [HTMLScriptElement[], boolean] {
|
|
102
|
+
const body = document.body
|
|
103
|
+
const a = readChain(document)
|
|
104
|
+
const b = readChain(doc)
|
|
105
|
+
const i = swapLevel(a, b)
|
|
106
|
+
const ta = tail(body)
|
|
107
|
+
const tb = tail(doc.body)
|
|
108
|
+
if (i && ta && tb) {
|
|
109
|
+
const old = between(a![i]!)
|
|
110
|
+
const next = between(b![i]!)
|
|
111
|
+
const run = classicScripts([...next, ...tb])
|
|
112
|
+
const keep = persist(old)
|
|
113
|
+
// focus resets as on a full load, also when the focused element sits in a kept level
|
|
114
|
+
;(document.activeElement as HTMLElement | null)?.blur?.()
|
|
115
|
+
for (const n of old.slice(1)) n.remove()
|
|
116
|
+
old[0]!.replaceWith(...next)
|
|
117
|
+
keep(next)
|
|
118
|
+
for (const n of ta) n.remove()
|
|
119
|
+
body.append(...tb)
|
|
120
|
+
return [run, false]
|
|
121
|
+
}
|
|
122
|
+
copyAttrs(document.documentElement, doc.documentElement, loading)
|
|
123
|
+
copyAttrs(body, doc.body)
|
|
124
|
+
const next = [...doc.body.childNodes]
|
|
125
|
+
const run = classicScripts(next)
|
|
126
|
+
const keep = persist([...body.childNodes])
|
|
127
|
+
body.replaceChildren(...next)
|
|
128
|
+
keep(next)
|
|
129
|
+
return [run, true]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const isClassic = (s: Element) => !(s as HTMLScriptElement).type || /java|ecma/i.test((s as HTMLScriptElement).type)
|
|
133
|
+
|
|
134
|
+
/** The classic scripts among `nodes` (and under them), in document order. */
|
|
135
|
+
export const classicScripts = (nodes: Node[]) => all(nodes, 'script').filter(isClassic) as HTMLScriptElement[]
|
|
136
|
+
|
|
137
|
+
/** Run each script as a full load would: re-created (a parsed one never runs), one after the other, a `src` awaited. */
|
|
138
|
+
export async function runScripts(list: HTMLScriptElement[]): Promise<void> {
|
|
139
|
+
for (const old of list) {
|
|
140
|
+
if (!old.isConnected) continue
|
|
141
|
+
const s = document.createElement('script')
|
|
142
|
+
for (const a of old.attributes) s.setAttribute(a.name, a.value)
|
|
143
|
+
s.text = old.text
|
|
144
|
+
const done = s.src && new Promise((r) => { s.onload = s.onerror = r })
|
|
145
|
+
old.replaceWith(s)
|
|
146
|
+
await done
|
|
147
|
+
}
|
|
148
|
+
}
|