@brust/runtime-dom 2.0.0-alpha.26 → 2.0.0-alpha.28

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.26",
3
+ "version": "2.0.0-alpha.28",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/src/nav/nav.ts CHANGED
@@ -1,17 +1,23 @@
1
- // SPA navigation (design docs/design/2026-10-25-spa-navigation-design.md): a qualifying link click, `navigate()` or
1
+ // SPA navigation (design): a qualifying link click, `navigate()` or
2
2
  // back/forward fetches the real HTML document and swaps it in place, so the page shows exactly what a full load of
3
3
  // that URL shows (§2). Anything that cannot be swapped is a full load. Events: `brust:nav:click|load|success|error`.
4
4
  import { applyHead, classicScripts, planHead, runScripts, swapBody } from './swap'
5
5
 
6
6
  type Trigger = 'link' | 'navigate' | 'popstate'
7
- export interface NavBridge { navigate(url: string, opts?: { replace?: boolean }): Promise<void> }
7
+ export interface NavBridge { navigate(url: string, opts?: { replace?: boolean }): Promise<void>; clearCache(): void }
8
8
  /** How a module script of the new page is loaded; tests swap it. */
9
9
  export type Load = (url: string) => Promise<unknown>
10
10
 
11
11
  const KEY = Symbol.for('brust.nav')
12
12
  const LOADING = 'data-brust-loading'
13
13
  const NAV = 'script[data-brust-nav]'
14
- const sameDoc = (a: string, b: string) => a.split('#')[0] === b.split('#')[0]
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'
15
21
 
16
22
  /** Boot once per tab. Returns a stop function (tests). */
17
23
  export function boot(load: Load = (u) => import(/* @vite-ignore */ u)): () => void {
@@ -45,14 +51,57 @@ export function boot(load: Load = (u) => import(/* @vite-ignore */ u)): () => vo
45
51
  t ? t.scrollIntoView() : scrollTo(0, 0)
46
52
  }
47
53
 
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
+ // ---- 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
+ }
54
99
  }
55
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
+
56
105
  /** The URL a link navigates to, or null when the click (or a prefetch) is left to the browser (§5.1). */
57
106
  const target = (t: EventTarget | null): [Element, string] | null => {
58
107
  const a = (t as Element | null)?.closest?.('a')
@@ -68,28 +117,27 @@ export function boot(load: Load = (u) => import(/* @vite-ignore */ u)): () => vo
68
117
  const l = target(t)
69
118
  const c = (navigator as { connection?: { saveData?: boolean; effectiveType?: string } }).connection
70
119
  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])
120
+ get(l[1], false, { priority: 'low' } as RequestInit).catch(() => {})
76
121
  }
77
122
 
78
- // ---- one navigation (§3); a newer one aborts it and it fires nothing more ----
79
- let active: AbortController | null = null
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
80
126
  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
127
+ vt?.skipTransition()
128
+ const me = ++active
129
+ const live = () => me === active
84
130
  const from = shown
85
131
  let url = req
86
132
  let status = 0
87
133
  let pushed = false
88
134
  const added: Element[] = []
89
135
  html.setAttribute(LOADING, '')
136
+ progress(true)
90
137
  fire('load', { id, url, from, trigger })
91
138
  try {
92
- const res = await (take(req) ?? fetch(req, { signal: c.signal }))
139
+ // not aborted when superseded: the response still fills the cache
140
+ const res = await get(req, trigger === 'popstate')
93
141
  if (!live()) return
94
142
  status = res.status
95
143
  // the final URL (after redirects); a fetch drops the fragment, which a redirect without one keeps
@@ -98,8 +146,7 @@ export function boot(load: Load = (u) => import(/* @vite-ignore */ u)): () => vo
98
146
  if (fin.origin !== location.origin) throw new Error('cross-origin')
99
147
  fin.hash ||= new URL(req).hash
100
148
  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
149
+ const doc = /html/.test(res.type) ? new DOMParser().parseFromString(res.text, 'text/html') : null
103
150
  if (doc?.querySelector(NAV)?.getAttribute('data-brust-build') !== build) throw new Error(`not a brust page of this build`)
104
151
 
105
152
  // §3 step 4: everything the new page needs before it is shown. The store tags first, ahead of the old ones (a
@@ -120,28 +167,70 @@ export function boot(load: Load = (u) => import(/* @vite-ignore */ u)): () => vo
120
167
  if (!live()) return
121
168
 
122
169
  // §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])
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))
138
224
  if (!live()) return
139
225
  html.removeAttribute(LOADING)
226
+ progress()
140
227
  if (status < 400) fire('success', { id, url, from, trigger, status })
141
228
  else fire('error', { id, url, from, trigger, status, error: undefined, swapped: true })
142
229
  } catch (error) {
143
230
  if (!live()) return
144
231
  html.removeAttribute(LOADING)
232
+ progress()
233
+ cache.delete(noHash(req))
145
234
  // nothing could be swapped: the default action is a full load of the URL (cancelable)
146
235
  if (fire('error', { id, url, from, trigger, status, error, swapped: false }, true)) location[pushed || trigger === 'popstate' ? 'replace' : 'assign'](url)
147
236
  } finally {
@@ -156,12 +245,14 @@ export function boot(load: Load = (u) => import(/* @vite-ignore */ u)): () => vo
156
245
  location[o?.replace ? 'replace' : 'assign'](url)
157
246
  return Promise.resolve()
158
247
  }
159
- g[KEY] = { navigate }
248
+ g[KEY] = { navigate, clearCache: () => cache.clear() }
160
249
 
161
250
  on(document, 'click', (e: MouseEvent) => {
162
251
  if (e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
163
252
  const l = target(e.target)
164
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)
165
256
  const id = ++seq
166
257
  if (!fire('click', { id, url: l[1], from: shown, link: l[0] }, true)) return
167
258
  e.preventDefault()
package/src/nav/swap.ts CHANGED
@@ -48,11 +48,14 @@ export function planHead(next: Element[]): HeadPlan {
48
48
  return { want, fixed, sheets }
49
49
  }
50
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[] {
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[] {
53
56
  const head = document.head
54
57
  const keep = new Set(want)
55
- for (const el of [...head.children]) if (!keep.has(el)) el.remove()
58
+ for (const el of [...head.children]) if (!keep.has(el) && served(el)) el.remove()
56
59
  const fresh: Element[] = []
57
60
  let prev: Element | null = null
58
61
  for (let el of want) {
@@ -134,8 +137,9 @@ const isClassic = (s: Element) => !(s as HTMLScriptElement).type || /java|ecma/i
134
137
  /** The classic scripts among `nodes` (and under them), in document order. */
135
138
  export const classicScripts = (nodes: Node[]) => all(nodes, 'script').filter(isClassic) as HTMLScriptElement[]
136
139
 
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> {
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> {
139
143
  for (const old of list) {
140
144
  if (!old.isConnected) continue
141
145
  const s = document.createElement('script')
@@ -143,6 +147,7 @@ export async function runScripts(list: HTMLScriptElement[]): Promise<void> {
143
147
  s.text = old.text
144
148
  const done = s.src && new Promise((r) => { s.onload = s.onerror = r })
145
149
  old.replaceWith(s)
150
+ ran?.(s)
146
151
  await done
147
152
  }
148
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]