@brust/runtime-dom 2.0.0-alpha.25 → 2.0.0-alpha.27

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brust/runtime-dom",
3
- "version": "2.0.0-alpha.25",
3
+ "version": "2.0.0-alpha.27",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -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/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
- export type IslandHydrate = (host: HTMLElement, props: Record<string, unknown>) => void
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 { hydrate(host, readIslandProps(host, id)); host.setAttribute('data-hydrated', '1') }
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
- /** Forget pending hosts under a subtree that left the document. */
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
- if (root instanceof HTMLElement && pending.has(root)) pending.delete(root)
81
- root.querySelectorAll<HTMLElement>('brust-island[data-id]').forEach((h) => pending.delete(h))
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
  }
@@ -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
+ }
@@ -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,282 @@
1
+ // SPA navigation (design): 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>; clearCache(): 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 noHash = (u: string) => u.split('#')[0]!
15
+ const sameDoc = (a: string, b: string) => noHash(a) === noHash(b)
16
+ /** A fetched document as the cache holds it (§10.1). `no`: never kept (`no-store`, a status of 400 or more). */
17
+ interface Got { status: number; url: string; type: string; text: string; no: boolean }
18
+ type VT = { skipTransition(): void; updateCallbackDone: Promise<void> }
19
+ const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches
20
+ const BAR = 'data-brust-bar'
21
+
22
+ /** Boot once per tab. Returns a stop function (tests). */
23
+ export function boot(load: Load = (u) => import(/* @vite-ignore */ u)): () => void {
24
+ const g = globalThis as { [KEY]?: NavBridge }
25
+ const build = document.querySelector(NAV)?.getAttribute('data-brust-build')
26
+ if (g[KEY] || build == null) return () => {}
27
+ const off = new AbortController()
28
+ const on = (t: EventTarget, type: string, h: (e: any) => void, capture?: boolean) =>
29
+ t.addEventListener(type, h, { capture, passive: type === 'touchstart', signal: off.signal })
30
+ const html = document.documentElement
31
+ const fire = (name: string, detail: object, cancelable?: boolean) =>
32
+ document.dispatchEvent(new CustomEvent('brust:nav:' + name, { detail, bubbles: true, cancelable }))
33
+
34
+ // ---- history entries: an id per entry in history.state.brust, scroll positions in memory (§5.7) ----
35
+ const stamp = Date.now().toString(36)
36
+ let n = 0
37
+ let seq = 0
38
+ const pos = new Map<string, [number, number]>()
39
+ const tag = (replaceUrl?: string) => {
40
+ const id = stamp + n++
41
+ history.replaceState({ ...history.state, brust: id }, '', replaceUrl)
42
+ return id
43
+ }
44
+ let cur: string = history.state?.brust ?? tag()
45
+ let shown = location.href
46
+ history.scrollRestoration = 'manual'
47
+ const land = (url: string, at?: [number, number]) => {
48
+ if (at) return scrollTo(at[0], at[1])
49
+ let t: Element | null = null
50
+ try { const h = decodeURIComponent(new URL(url).hash.slice(1)); t = h ? document.getElementById(h) : null } catch {}
51
+ t ? t.scrollIntoView() : scrollTo(0, 0)
52
+ }
53
+
54
+ // ---- the page cache (§10.1): an LRU of 32 documents by URL without its hash, filled by prefetches and navigations,
55
+ // fresh for 5 minutes (`data-brust-cache` seconds), any age for back/forward; a fetch in flight (at: Infinity) is shared
56
+ const cache = new Map<string, [Promise<Got>, number]>()
57
+ const ttl = () => { const s = html.getAttribute('data-brust-cache'); return s == null || isNaN(+s) ? 3e5 : +s * 1e3 }
58
+ const get = (u: string, any?: boolean, init?: RequestInit): Promise<Got> => {
59
+ const k = noHash(u)
60
+ let e = cache.get(k)
61
+ cache.delete(k)
62
+ if (!e || !(any || Date.now() - e[1] < ttl())) {
63
+ const p = fetch(k, init).then(async (r) => ({ status: r.status, url: r.url, type: r.headers.get('content-type') ?? '', text: await r.text(), no: r.status >= 400 || /no-store/i.test(r.headers.get('cache-control') ?? '') }))
64
+ const f = (e = [p, Infinity])
65
+ const drop = () => cache.get(k) === f && cache.delete(k)
66
+ p.then((g) => (g.no || !ttl() ? drop() : (f[1] = Date.now())), drop)
67
+ }
68
+ cache.set(k, e)
69
+ if (cache.size > 32) cache.delete(cache.keys().next().value!)
70
+ return e[0]
71
+ }
72
+
73
+ // ---- the progress bar (§10.2): only for a navigation still loading 150 ms after `load`; outside <body>, so no swap
74
+ // removes it, and its <style> is runtime-added, so the head reconcile keeps it ----
75
+ let bar: Element | undefined
76
+ let css: HTMLStyleElement | undefined
77
+ let wait: ReturnType<typeof setTimeout> | undefined
78
+ const progress = (on?: boolean) => {
79
+ clearTimeout(wait)
80
+ if (on) {
81
+ if (!bar && html.getAttribute('data-brust-progress') !== 'off') wait = setTimeout(() => {
82
+ if (!css) {
83
+ ;(css = document.createElement('style')).textContent =
84
+ `div[${BAR}]{position:fixed;z-index:2147483647;top:0;left:0;width:100%;height:3px;pointer-events:none;background:var(--brust-progress-color,#2f7cf6);transform-origin:0;transform:scaleX(0);animation:brust-bar 8s cubic-bezier(.1,.7,.3,1) forwards}` +
85
+ `div[${BAR}=done]{animation:none;transform:none;opacity:0;transition:transform .2s,opacity .3s .2s}@keyframes brust-bar{to{transform:scaleX(.9)}}` +
86
+ `@media (prefers-reduced-motion:reduce){div[${BAR}]{animation:none;transform:scaleX(.9)}}`
87
+ css.setAttribute(BAR, '')
88
+ }
89
+ if (!css.isConnected) document.head.append(css)
90
+ html.append((bar = document.createElement('div')))
91
+ bar.setAttribute(BAR, '')
92
+ }, 150)
93
+ } else if (bar) {
94
+ const b = bar
95
+ bar = undefined
96
+ b.setAttribute(BAR, 'done')
97
+ setTimeout(() => b.remove(), reduced() ? 0 : 500)
98
+ }
99
+ }
100
+
101
+ // the head elements that came from the server (there at boot, or inserted from a fetched document): the only ones the
102
+ // head reconcile may remove (§3 step 5)
103
+ const served = new WeakSet<Element>(document.head.children)
104
+
105
+ /** The URL a link navigates to, or null when the click (or a prefetch) is left to the browser (§5.1). */
106
+ const target = (t: EventTarget | null): [Element, string] | null => {
107
+ const a = (t as Element | null)?.closest?.('a')
108
+ const href = a?.getAttribute('href')
109
+ if (href == null || a!.hasAttribute('download') || (a!.getAttribute('target') || '_self').toLowerCase() !== '_self' || a!.closest('[data-brust-reload]')) return null
110
+ let u: URL
111
+ try { u = new URL(href, document.baseURI) } catch { return null }
112
+ if (u.origin !== location.origin || !/^https?:$/.test(u.protocol) || (u.href.includes('#') && sameDoc(u.href, location.href))) return null
113
+ return [a!, u.href]
114
+ }
115
+
116
+ const prefetch = (t: EventTarget | null) => {
117
+ const l = target(t)
118
+ const c = (navigator as { connection?: { saveData?: boolean; effectiveType?: string } }).connection
119
+ if (!l || l[0].closest('[data-brust-prefetch="false"]') || c?.saveData || /2g/.test(c?.effectiveType ?? '')) return
120
+ get(l[1], false, { priority: 'low' } as RequestInit).catch(() => {})
121
+ }
122
+
123
+ // ---- one navigation (§3); a newer one supersedes it (and skips its transition) and it fires nothing more ----
124
+ let active = 0
125
+ let vt: VT | undefined
126
+ const go = async (req: string, trigger: Trigger, id: number, replace: boolean, pop?: string): Promise<void> => {
127
+ vt?.skipTransition()
128
+ const me = ++active
129
+ const live = () => me === active
130
+ const from = shown
131
+ let url = req
132
+ let status = 0
133
+ let pushed = false
134
+ const added: Element[] = []
135
+ html.setAttribute(LOADING, '')
136
+ progress(true)
137
+ fire('load', { id, url, from, trigger })
138
+ try {
139
+ // not aborted when superseded: the response still fills the cache
140
+ const res = await get(req, trigger === 'popstate')
141
+ if (!live()) return
142
+ status = res.status
143
+ // the final URL (after redirects); a fetch drops the fragment, which a redirect without one keeps
144
+ const fin = new URL(res.url || req)
145
+ // a redirect that left this origin (an open redirect to a CORS-readable page) is never parsed: a full load
146
+ if (fin.origin !== location.origin) throw new Error('cross-origin')
147
+ fin.hash ||= new URL(req).hash
148
+ url = fin.href
149
+ const doc = /html/.test(res.type) ? new DOMParser().parseFromString(res.text, 'text/html') : null
150
+ if (doc?.querySelector(NAV)?.getAttribute('data-brust-build') !== build) throw new Error(`not a brust page of this build`)
151
+
152
+ // §3 step 4: everything the new page needs before it is shown. The store tags first, ahead of the old ones (a
153
+ // store seeds from the first tag of its name; a live store never re-reads one), then the module scripts in
154
+ // document order (one at a time: the order they register in is the order the server wrote), then the stylesheets
155
+ // the head plan inserted, with `media="not all"`: loaded and parsed, applied to nothing until the swap.
156
+ const stores = Array.from(doc.querySelectorAll('script[data-brust-store]'), (s) => document.importNode(s, true))
157
+ document.head.append(...stores)
158
+ added.push(...stores)
159
+ for (const s of doc.querySelectorAll('script[type=module][src]')) {
160
+ if (!s.matches(NAV)) await load(new URL(s.getAttribute('src')!, url).href)
161
+ if (!live()) return
162
+ }
163
+ const plan = planHead([...doc.head.children])
164
+ added.push(...plan.sheets.map(([e]) => e))
165
+ // a load event is a later task: the handlers set now still catch a cached sheet
166
+ await Promise.all(plan.sheets.map(([e]) => new Promise((r) => { (e as HTMLLinkElement).onload = (e as HTMLLinkElement).onerror = r })))
167
+ if (!live()) return
168
+
169
+ // §3 step 5: swap (one task, nothing painted in between), then history and scroll
170
+ let fresh: Element[] = []
171
+ let body: HTMLScriptElement[] = []
172
+ let whole = false
173
+ const swap = () => {
174
+ if (!live()) return
175
+ for (const [e, m] of plan.sheets) m == null ? e.removeAttribute('media') : e.setAttribute('media', m)
176
+ pos.set(cur, [scrollX, scrollY])
177
+ for (const e of added) served.add(e)
178
+ fresh = applyHead(plan, (e) => served.has(e))
179
+ for (const e of fresh) served.add(e)
180
+ ;[body, whole] = swapBody(doc, LOADING)
181
+ added.length = 0
182
+ if (trigger === 'popstate') cur = pop || tag()
183
+ else if (replace) history.replaceState({ ...history.state, brust: cur }, '', url)
184
+ else history.pushState({ brust: (cur = stamp + n++) }, '', url)
185
+ pushed = true
186
+ shown = location.href
187
+ land(url, pos.get(pop!))
188
+ }
189
+ // §10.3: the swap inside a view transition (a root cross-fade). Everything slow is done by now. The first 8 images of
190
+ // the new page that are not lazy and not on screen already are preloaded (the same candidate: srcset, sizes,
191
+ // crossorigin and referrerpolicy copied) and get 100 ms in all to decode, so none pops in after the fade. `swap`
192
+ // runs once: a throw from it rejects updateCallbackDone and takes the error path; a throw from startViewTransition
193
+ // itself is a direct swap.
194
+ const start = (document as { startViewTransition?: (u: () => void) => VT }).startViewTransition
195
+ let t: VT | undefined
196
+ if (start && html.getAttribute('data-brust-transition') !== 'off' && !reduced()) {
197
+ const abs = (u: string | null) => (u ? new URL(u, url).href : '')
198
+ const onScreen = new Set([...document.querySelectorAll('img')].flatMap((i) => [i.src, i.currentSrc]).filter(Boolean))
199
+ await Promise.race([
200
+ new Promise((r) => setTimeout(r, 100)),
201
+ Promise.all([...doc.querySelectorAll('img')]
202
+ .filter((i) => i.getAttribute('loading') !== 'lazy' && (i.hasAttribute('src') || i.hasAttribute('srcset')) && !onScreen.has(abs(i.getAttribute('src'))))
203
+ .slice(0, 8)
204
+ .map((i) => {
205
+ const m = new Image()
206
+ for (const a of ['crossorigin', 'referrerpolicy', 'sizes']) if (i.hasAttribute(a)) m.setAttribute(a, i.getAttribute(a)!)
207
+ const set = i.getAttribute('srcset')
208
+ if (set) m.srcset = set.replace(/(^|,)(\s*)([^\s,]+)/g, (_, c, w, u) => c + w + abs(u))
209
+ if (i.hasAttribute('src')) m.src = abs(i.getAttribute('src'))
210
+ return m.decode().catch(() => {})
211
+ })),
212
+ ])
213
+ if (!live()) return
214
+ try { vt = t = start.call(document, swap) } catch {}
215
+ }
216
+ t ? await t.updateCallbackDone : swap()
217
+ if (!live()) return
218
+ // the head scripts new to it run; after a whole-body swap (<html> and <body> attributes replaced) every one the
219
+ // server sent runs again, as on a full load (a theme script setting <html class>), but a data-brust-once one only
220
+ // if it is new, and one a component added at runtime never (a loader such as Stripe.js must not run twice). A
221
+ // re-created script is the server's too, so the next reconcile still removes it.
222
+ const head = whole ? [...document.head.children].filter((e) => served.has(e) && (!e.hasAttribute('data-brust-once') || fresh.includes(e))) : fresh
223
+ await runScripts([...classicScripts(head), ...body], (s) => served.add(s))
224
+ if (!live()) return
225
+ html.removeAttribute(LOADING)
226
+ progress()
227
+ if (status < 400) fire('success', { id, url, from, trigger, status })
228
+ else fire('error', { id, url, from, trigger, status, error: undefined, swapped: true })
229
+ } catch (error) {
230
+ if (!live()) return
231
+ html.removeAttribute(LOADING)
232
+ progress()
233
+ cache.delete(noHash(req))
234
+ // nothing could be swapped: the default action is a full load of the URL (cancelable)
235
+ if (fire('error', { id, url, from, trigger, status, error, swapped: false }, true)) location[pushed || trigger === 'popstate' ? 'replace' : 'assign'](url)
236
+ } finally {
237
+ for (const e of added) e.remove()
238
+ }
239
+ }
240
+
241
+ const navigate = (u: string, o?: { replace?: boolean }): Promise<void> => {
242
+ const url = new URL(u, location.href).href
243
+ if (url.startsWith(location.origin + '/') && !document.body.closest('[data-brust-reload]') && !(url.includes('#') && sameDoc(url, location.href)))
244
+ return go(url, 'navigate', ++seq, !!o?.replace || sameDoc(url, location.href))
245
+ location[o?.replace ? 'replace' : 'assign'](url)
246
+ return Promise.resolve()
247
+ }
248
+ g[KEY] = { navigate, clearCache: () => cache.clear() }
249
+
250
+ on(document, 'click', (e: MouseEvent) => {
251
+ if (e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
252
+ const l = target(e.target)
253
+ if (!l) return
254
+ // the click navigates now: a hover intent still pending would refetch a page the cache does not keep
255
+ clearTimeout(hover)
256
+ const id = ++seq
257
+ if (!fire('click', { id, url: l[1], from: shown, link: l[0] }, true)) return
258
+ e.preventDefault()
259
+ void go(l[1], 'link', id, sameDoc(l[1], location.href))
260
+ })
261
+ let hover: ReturnType<typeof setTimeout> | undefined
262
+ const isA = (t: EventTarget | null) => (t as Element | null)?.localName === 'a'
263
+ on(document, 'pointerenter', (e) => { if (isA(e.target)) hover = setTimeout(() => prefetch(e.target), 65) }, true)
264
+ on(document, 'pointerleave', (e) => { if (isA(e.target)) clearTimeout(hover) }, true)
265
+ on(document, 'focusin', (e) => prefetch(e.target))
266
+ on(document, 'touchstart', (e) => prefetch(e.target), true)
267
+ on(window, 'popstate', (e: PopStateEvent) => {
268
+ const to: string | undefined = e.state?.brust
269
+ if (sameDoc(location.href, shown)) {
270
+ // a fragment entry of the page on screen: the browser owns it; keep the scroll of the entry left
271
+ pos.set(cur, [scrollX, scrollY])
272
+ cur = to || tag()
273
+ shown = location.href
274
+ return land(shown, pos.get(cur))
275
+ }
276
+ void go(location.href, 'popstate', ++seq, false, to)
277
+ })
278
+ return () => {
279
+ off.abort()
280
+ delete g[KEY]
281
+ }
282
+ }
@@ -0,0 +1,153 @@
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
+ /**
52
+ * Make the head's element children `plan.want`, moving no fixed element. An old element not wanted is removed only if
53
+ * `served` says it came from the server: one a live component added at runtime stays. Returns the elements new to it.
54
+ */
55
+ export function applyHead({ want, fixed }: HeadPlan, served: (el: Element) => boolean = () => true): Element[] {
56
+ const head = document.head
57
+ const keep = new Set(want)
58
+ for (const el of [...head.children]) if (!keep.has(el) && served(el)) el.remove()
59
+ const fresh: Element[] = []
60
+ let prev: Element | null = null
61
+ for (let el of want) {
62
+ if (el.ownerDocument !== document) fresh.push((el = document.adoptNode(el)))
63
+ // the fixed elements are already in order: every other one goes right after the one before it
64
+ if (!fixed.has(el) && (prev ? prev.nextElementSibling : head.firstElementChild) !== el) prev ? prev.after(el) : head.prepend(el)
65
+ prev = el
66
+ }
67
+ return fresh
68
+ }
69
+
70
+ function copyAttrs(to: Element, from: Element, keep?: string): void {
71
+ for (const a of [...to.attributes]) if (a.name !== keep && !from.hasAttribute(a.name)) to.removeAttribute(a.name)
72
+ for (const a of from.attributes) to.setAttribute(a.name, a.value)
73
+ }
74
+
75
+ const all = (nodes: Node[], sel: string) =>
76
+ nodes.flatMap((n) => (n instanceof Element ? [...(n.matches(sel) ? [n] : []), ...n.querySelectorAll(sel)] : []))
77
+
78
+ const P = '[data-brust-persist]'
79
+ const key = (el: Element) => el.getAttribute('data-brust-persist')!
80
+
81
+ /**
82
+ * A live `data-brust-persist` element replaces the inserted element with the same key, in the same task as the swap:
83
+ * it never leaves this document (no adoption into the parsed one), so it is moved, never disposed.
84
+ */
85
+ function persist(old: Node[]): (next: Node[]) => void {
86
+ const live = new Map(all(old, P).map((el) => [key(el), el]))
87
+ return (next) => { for (const el of all(next, P)) { const k = live.get(key(el)); if (k) el.replaceWith(k) } }
88
+ }
89
+
90
+ /** The body-end assets: the nav tag and every node after it (store tags, module scripts). */
91
+ function tail(body: HTMLElement): ChildNode[] | null {
92
+ const tag = body.querySelector(':scope > script[data-brust-nav]')
93
+ const out: ChildNode[] = []
94
+ for (let n: ChildNode | null = tag; n; n = n.nextSibling) out.push(n)
95
+ return tag && out
96
+ }
97
+
98
+ /**
99
+ * Swap the body to `doc`'s (§5.9): from the first level that changed, or the whole body (with the attributes of
100
+ * `<html>` and `<body>`) when the root changed or a chain cannot be trusted. Returns the classic scripts of the new
101
+ * nodes, taken before the persisted elements go in (a live one's scripts already ran: they never run again), and true
102
+ * for a whole-body swap.
103
+ */
104
+ export function swapBody(doc: Document, loading: string): [HTMLScriptElement[], boolean] {
105
+ const body = document.body
106
+ const a = readChain(document)
107
+ const b = readChain(doc)
108
+ const i = swapLevel(a, b)
109
+ const ta = tail(body)
110
+ const tb = tail(doc.body)
111
+ if (i && ta && tb) {
112
+ const old = between(a![i]!)
113
+ const next = between(b![i]!)
114
+ const run = classicScripts([...next, ...tb])
115
+ const keep = persist(old)
116
+ // focus resets as on a full load, also when the focused element sits in a kept level
117
+ ;(document.activeElement as HTMLElement | null)?.blur?.()
118
+ for (const n of old.slice(1)) n.remove()
119
+ old[0]!.replaceWith(...next)
120
+ keep(next)
121
+ for (const n of ta) n.remove()
122
+ body.append(...tb)
123
+ return [run, false]
124
+ }
125
+ copyAttrs(document.documentElement, doc.documentElement, loading)
126
+ copyAttrs(body, doc.body)
127
+ const next = [...doc.body.childNodes]
128
+ const run = classicScripts(next)
129
+ const keep = persist([...body.childNodes])
130
+ body.replaceChildren(...next)
131
+ keep(next)
132
+ return [run, true]
133
+ }
134
+
135
+ const isClassic = (s: Element) => !(s as HTMLScriptElement).type || /java|ecma/i.test((s as HTMLScriptElement).type)
136
+
137
+ /** The classic scripts among `nodes` (and under them), in document order. */
138
+ export const classicScripts = (nodes: Node[]) => all(nodes, 'script').filter(isClassic) as HTMLScriptElement[]
139
+
140
+ /** Run each script as a full load would: re-created (a parsed one never runs), one after the other, a `src` awaited.
141
+ * `ran` gets each replacement, which stands for the old node from then on. */
142
+ export async function runScripts(list: HTMLScriptElement[], ran?: (s: HTMLScriptElement) => void): Promise<void> {
143
+ for (const old of list) {
144
+ if (!old.isConnected) continue
145
+ const s = document.createElement('script')
146
+ for (const a of old.attributes) s.setAttribute(a.name, a.value)
147
+ s.text = old.text
148
+ const done = s.src && new Promise((r) => { s.onload = s.onerror = r })
149
+ old.replaceWith(s)
150
+ ran?.(s)
151
+ await done
152
+ }
153
+ }
@@ -1,7 +1,7 @@
1
1
  // defineStore (M3-F D1): the 0.1.x `brustjs/store` handle on the shared reactive core and the
2
2
  // globalThis registry. Resolution (browser registry / server resolver) lives in ./registry.
3
3
  import { type Computed, type Signal, isSignal } from '../core-api'
4
- import { type StoreInstanceRecord, computeSnapshot, resolveRecord } from './registry'
4
+ import { type StoreInstanceRecord, computeSnapshot, resolveRecord, ssrSnapshotOf } from './registry'
5
5
 
6
6
  // Unwrap a Signal/Computed to its value type; `never` if it is neither. Uses `infer` (not
7
7
  // `extends Signal<unknown>`): `Signal<T>.set(next: T)` is contravariant in T, so
@@ -83,7 +83,10 @@ export function defineStore<S extends object>(name: string, factory: () => S): S
83
83
  return computeSnapshot(rec) as Snapshot<S>
84
84
  }
85
85
  // The snapshot the SSR HTML was rendered from (browser); React islands hydrate against it. Absent on the server.
86
- ;(handle as unknown as Record<symbol, unknown>)[Symbol.for('brust.ssrSnapshot')] = () => resolve().ssr ?? handle.snapshot()
86
+ ;(handle as unknown as Record<symbol, unknown>)[Symbol.for('brust.ssrSnapshot')] = () => {
87
+ const rec = resolve()
88
+ return ssrSnapshotOf(name, rec) ?? handle.snapshot()
89
+ }
87
90
  handle.serialize = () => {
88
91
  const out: Record<string, unknown> = {}
89
92
  for (const [k, s] of Object.entries(resolve().instance)) {
@@ -14,6 +14,8 @@ export interface StoreInstanceRecord {
14
14
  snap: { value: Record<string, unknown>; version: number } | null
15
15
  /** Browser only: the snapshot the SSR HTML was rendered from (script seed applied, nothing written since). Islands hydrate against it (G13). */
16
16
  ssr?: Record<string, unknown>
17
+ /** Browser only: the seed tag `ssr` was read from (`null` when the page carried none). */
18
+ ssrSeed?: Element | null
17
19
  handle?: { serialize(): Record<string, unknown> }
18
20
  }
19
21
 
@@ -76,12 +78,30 @@ export function resolveRecord(
76
78
  const made = (rec = reg[name] = create(false))
77
79
  const el = seedScript(name)
78
80
  if (el) seed(rec, parseStoreScript(el))
81
+ rec.ssrSeed = el
79
82
  // Identity-stable: the same object stays `rec.snap` until the first write.
80
83
  rec.ssr = untracked(() => computeSnapshot(made)) // a first access inside an effect must not subscribe it
81
84
  }
82
85
  return rec
83
86
  }
84
87
 
88
+ const tagSnapshots = new WeakMap<Element, Record<string, unknown>>()
89
+
90
+ /**
91
+ * The snapshot an island hydrating NOW must render from: the seed tag of the document it came from. A live store keeps
92
+ * `rec.ssr` from the first page (§5.5 of the SPA design), so an island on a page navigated to would otherwise hydrate
93
+ * against the first page's values. Keys the tag does not carry (computeds) keep the first snapshot's value.
94
+ */
95
+ export function ssrSnapshotOf(name: string, rec: StoreInstanceRecord): Record<string, unknown> | undefined {
96
+ const base = rec.ssr
97
+ if (!base || typeof document === 'undefined') return base
98
+ const el = seedScript(name)
99
+ if (!el || el === rec.ssrSeed) return base
100
+ let snap = tagSnapshots.get(el)
101
+ if (!snap) tagSnapshots.set(el, (snap = { ...base, ...parseStoreScript(el) }))
102
+ return snap
103
+ }
104
+
85
105
  /** Apply a navigation-payload snapshot to live client stores. Reserved for the SPA wave (D9): no callers yet. */
86
106
  export function applyStoreSnapshot(snap: Record<string, Record<string, unknown>>): void {
87
107
  const reg = (globalThis as { [STORES]?: Registry })[STORES]