@christianirack/brainllm-runtime 0.6.1 → 0.7.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.
package/brain-router.js CHANGED
@@ -17,16 +17,14 @@ import { prefetchPage } from './brain-prefetch-shared.js'
17
17
  const G = globalThis
18
18
  // Base path (<meta name="brain-base" content="/app/">, §23 #148): links in the HTML already carry it; guards and
19
19
  // routes work with app paths (strip), and app paths given to go()/navigate()/redirect() get it (app). '/' when absent.
20
- const base = () => {
21
- const m = G.document && document.querySelector('meta[name=brain-base]')
22
- return (m && m.getAttribute('content')) || '/'
23
- }
20
+ const base = () => G.document?.querySelector('meta[name=brain-base]')?.getAttribute('content') || '/'
24
21
  const strip = p => {
25
22
  const b = base()
26
23
  return b !== '/' && (p + '/').startsWith(b) ? '/' + p.slice(b.length) : p
27
24
  }
28
25
  const app = u => {
29
26
  const b = base()
27
+ u = G.__brainHref?.(u) ?? u // the page's language (brain-links.js)
30
28
  return typeof u === 'string' && b !== '/' && u.startsWith('/') && !u.startsWith('//') && !(u + '/').startsWith(b) ? b + u.slice(1) : u
31
29
  }
32
30
 
@@ -44,7 +42,7 @@ G.__brainNavigate = navigate // brain-form follows a server function's { redirec
44
42
 
45
43
  // route() lives in brain-env.js (it needs the core's signals; a page without islands downloads only this router):
46
44
  // after every client navigation the router calls the hook it installs.
47
- const routed = () => G.__brainRouted && G.__brainRouted()
45
+ const routed = () => G.__brainRouted?.()
48
46
 
49
47
  // Active links (§41.5): every internal <a href> to the current page gets aria-current="page" (the CSS hook is
50
48
  // [aria-current=page]); the others lose it. On start and after every client navigation.
@@ -70,7 +68,6 @@ const leave = () => {
70
68
  }
71
69
 
72
70
  // Same shapes as server.mjs, so one _guard.jsbrain works on both sides: { redirect, status } and { deny }
73
- const R = Symbol('redirect') // older compiled guards
74
71
  export function redirect(url, status = 303) {
75
72
  throw { redirect: String(url), status }
76
73
  }
@@ -78,13 +75,11 @@ export const deny = (reason = 'Forbidden') => ({ deny: String(reason) })
78
75
  // guard result → true (go on) · false (cancel) · string (redirect) · null (full load: the server decides, e.g. 403)
79
76
  const outcome = v =>
80
77
  v && typeof v === 'object'
81
- ? R in v
82
- ? v[R]
83
- : typeof v.redirect === 'string'
84
- ? v.redirect
85
- : v.deny !== undefined
86
- ? null
87
- : true
78
+ ? typeof v.redirect === 'string'
79
+ ? v.redirect
80
+ : v.deny !== undefined
81
+ ? null
82
+ : true
88
83
  : v === false || typeof v === 'string'
89
84
  ? v
90
85
  : true
@@ -92,18 +87,21 @@ const outcome = v =>
92
87
  export function router(o = {}) {
93
88
  const region = o.region || 'main'
94
89
  const parse = o.parse || (h => new DOMParser().parseFromString(h, 'text/html'))
95
- const fullLoad = new Set(Array.isArray(o.fullLoad) ? o.fullLoad.map(p => { try { return new URL(p, location.href).pathname.replace(/\/+$/, '') || '/' } catch { return null } }).filter(Boolean) : [])
90
+ const fullLoad = new Set((o.fullLoad || []).map(p => new URL(p, location.href).pathname.replace(/\/+$/, '') || '/'))
96
91
  let seq = 0, through = null, stale = false
97
- const ahead = predict(o, u => same(u, true))
92
+ const ahead = predict(o, (u, p = true) => same(u, p))
98
93
  // Build signature (§35.7): the page's <meta name="brain-build">. A navigation response from another build, or the
99
94
  // service worker announcing one, turns client navigation into full loads so old and new code never mix.
100
- const build = () => {
101
- const m = document.querySelector('meta[name=brain-build]')
102
- return m ? m.getAttribute('content') : null
103
- }
104
- const sw = G.navigator && navigator.serviceWorker
105
- const onMsg = e => typeof e.data === 'string' && e.data.startsWith('brain:build ') && build() && e.data.slice(12) !== build() && (stale = true, ahead.stop())
106
- if (sw && sw.addEventListener) sw.addEventListener('message', onMsg)
95
+ const build = () => document.querySelector('meta[name=brain-build]')?.getAttribute('content')
96
+ // The page's CSP nonce, read once while the document is still the server's: later, data scripts adopted from fetched
97
+ // pages (JSON-LD, b/state) carry their own response's nonce and could come first, and a mount script recreated with
98
+ // that nonce is blocked by the CSP.
99
+ const own = document.querySelector('script[nonce]'), nonce = own?.nonce || own?.getAttribute('nonce')
100
+ const sw = G.navigator?.serviceWorker
101
+ const onMsg = e => /^brain:build /.test(e.data) && build() && e.data.slice(12) !== build() && (stale = true, ahead.stop())
102
+ sw?.addEventListener?.('message', onMsg)
103
+ // both routers stop through here, so neither keeps the service worker listener alive (B05)
104
+ const quit = () => ((active = null), ahead.stop(), sw?.removeEventListener?.('message', onMsg))
107
105
  // a guard is a function, or the URL of its `_guard.jsbrain` chunk (BUILD #220): loaded the first time a navigation
108
106
  // reaches that folder, then cached by the module map; its default export runs with the same ctx
109
107
  const gs = Object.entries(o.guards || {})
@@ -125,7 +123,7 @@ export function router(o = {}) {
125
123
  v = await g(ctx)
126
124
  } catch (e) {
127
125
  if (asked) return null
128
- return e && typeof e === 'object' && (R in e || 'redirect' in e) ? outcome(e) : null
126
+ return typeof e?.redirect === 'string' ? e.redirect : null
129
127
  }
130
128
  v = outcome(v)
131
129
  if (v !== true) return asked ? null : v
@@ -133,106 +131,155 @@ export function router(o = {}) {
133
131
  return true
134
132
  }
135
133
 
136
- // _loading.brain (#235): when the next page takes longer than 150 ms, the nearest loading view of the target route
137
- // (`loading: [{ rx: string | string[], view }]`, most specific first, emitted by the build with the `core` URL) is mounted in the
138
- // region until the page arrives; fast navigations never show it, so nothing flickers.
139
- const loads = o.loading || []
134
+ // Feedback (§19): a navigation marks the region aria-busy and route().navigating at once; past 150 ms it shows
135
+ // #brain-nav: the app's own element with that id (a spinner in app.brain, `<div id="brain-nav" hidden>`) or a native
136
+ // indeterminate <progress> bar at the top (label "Loading"; colored by accent-color on #brain-nav, no extra
137
+ // DOM), so fast ones show nothing. _loading.brain (#235): past 400 ms the nearest loading view of the target route
138
+ // (`loading: [{ rx, view }]`, most specific first, emitted by the build with the `core` URL) replaces the region until
139
+ // the page arrives. Until then the current page stays as it is.
140
+ const loads = o.loading || [], adopt = n => document.adoptNode?.(n) || n
141
+ // the previous page's click hold ends with it, mounted or not
142
+ const dispose = r => { r.$hold?.(); for (const e of [r, ...r.querySelectorAll('*')]) if (e.$d) e.$d(), (e.$d = null) }
143
+ const fixed = (t, id, css) => {
144
+ const b = document.body.appendChild(document.createElement(t))
145
+ b.id = id
146
+ b.style.cssText = 'position:fixed;' + css
147
+ return b
148
+ }
149
+ const ui = on => {
150
+ let b = document.getElementById('brain-nav')
151
+ if (!b && on) (b = fixed('progress', 'brain-nav', 'top:0;left:0;width:100%;height:3px')).ariaLabel = 'Loading'
152
+ if (b) b.hidden = !on
153
+ }
154
+ const busy = (my, on) => {
155
+ const el = my === seq && document.querySelector(region)
156
+ if (!el) return
157
+ on ? el.setAttribute('aria-busy', 'true') : (el.removeAttribute('aria-busy'), ui())
158
+ G.__brainRouted?.(on)
159
+ }
140
160
  function waiting(url, my) {
141
- const path = strip(new URL(url, location.href).pathname), l = loads.find(x => (Array.isArray(x.rx) ? x.rx : [x.rx]).some(rx => new RegExp(rx).test(path)))
142
- const w = { done: false, t: 0 }
143
- if (!l || !o.core) return w
144
- w.t = setTimeout(() => {
145
- Promise.all([import(o.core), import(l.view)])
146
- .then(([c, v]) => {
147
- const el = document.querySelector(region), mount = c.m || c.mount
148
- if (w.done || my !== seq || !el || !mount) return // the page arrived first
149
- for (const e of [el, ...walkAll(el)]) if (e.$d) e.$d(), (e.$d = null)
150
- for (const n of [...el.childNodes]) if (!/^(STYLE|LINK)$/.test(n.nodeName)) n.remove() // the view's CSS stays (BUILD #35)
151
- el.$d = mount(v.default, el, {})
152
- })
153
- .catch(() => {})
154
- }, 150)
161
+ const path = strip(new URL(url, location.href).pathname), l = loads.find(x => [x.rx].flat().some(rx => new RegExp(rx).test(path)))
162
+ const w = {}, at = (ms, f) => setTimeout(() => my === seq && !w.done && f(), ms)
163
+ at(150, () => ui(1))
164
+ at(8e3, () => (seq++, hard(url))) // a request that never answers (a hung prefetch included) ends in a full load
165
+ if (l && o.core)
166
+ at(400, () =>
167
+ Promise.all([import(o.core), import(l.view)])
168
+ .then(([c, v]) => {
169
+ const el = document.querySelector(region), mount = c.m || c.mount
170
+ if (w.done || my !== seq || !el || !mount) return // the page arrived first
171
+ dispose(el)
172
+ for (const n of [...el.childNodes]) if (!/^(STYLE|LINK)$/.test(n.nodeName)) n.remove() // the view's CSS stays (BUILD #35)
173
+ el.$d = mount(v.default, el, {})
174
+ })
175
+ .catch(() => {}),
176
+ )
155
177
  return w
156
178
  }
157
179
 
180
+ let navFetch // the navigation in flight: a newer one aborts its fetch
181
+ // → true when this navigation swapped the region (a newer one, a full load or a failure → falsy)
158
182
  async function load(url) {
159
183
  const my = ++seq
160
- let doc, r
161
184
  if (stale) return hard(url)
185
+ navFetch?.abort()
186
+ const ctl = (navFetch = new AbortController())
162
187
  const slow = waiting(url, my)
188
+ busy(my, 1)
163
189
  // the previous route's background requests (prefetch) are dropped by brain-data's queue (§41.4)
190
+ G.dispatchEvent?.(new Event('brain:navigate'))
164
191
  try {
165
- G.dispatchEvent && G.dispatchEvent(new Event('brain:navigate'))
166
- } catch {}
167
- try {
168
- r = (await ahead.take(url)) || (await fetch(url, { headers: { 'x-brain-nav': '1' } }))
169
- const b = r.headers && r.headers.get && r.headers.get('x-brain-build')
170
- if (b && build() && b !== build()) return void (my === seq && hard(url)) // a new deploy: load it whole
171
- const ct = (r.headers && r.headers.get && r.headers.get('content-type')) || 'text/html'
172
- if (!r.ok || !/html/.test(ct)) throw Error(r.status)
173
- doc = parse(await r.text())
174
- } catch {
175
- return void ((slow.done = true), clearTimeout(slow.t), my === seq && hard(url))
176
- }
177
- slow.done = true
178
- clearTimeout(slow.t)
179
- if (my !== seq) return // a newer navigation won
180
- // another language (§37): a full load brings its own dictionaries, `lang` and `dir`; locales never mix in memory
181
- const lang = d => d && d.documentElement && d.documentElement.getAttribute('lang')
182
- if (lang(doc) && lang(document) && lang(doc) !== lang(document)) return hard(url)
183
- if (r.redirected && r.url && G.history && r.url !== new URL(url, location.href).href) history.replaceState(history.state, '', r.url)
184
- const cur = document.querySelector(region), next = doc.querySelector(region)
185
- if (!cur || !next) return hard(url)
186
- const swap = () => {
187
- // read before the swap: afterwards the fetched page's scripts are in the document too
188
- const own = document.querySelector('script[nonce]'), nonce = own && (own.nonce || own.getAttribute('nonce'))
189
- for (const el of [cur, ...walkAll(cur)]) if (el.$d) el.$d(), (el.$d = null)
190
- cur.replaceChildren(...[...next.childNodes].map(n => (document.adoptNode ? document.adoptNode(n) : n)))
191
- // Scripts parsed by DOMParser never run: the region's own scripts (the view's mount, `e.$d = mount(...)`) are
192
- // recreated so they execute. Already-imported modules come from the module map: their state survives.
193
- // The page's CSP only trusts its own nonce: a recreated script carries the current page's nonce, never the one
194
- // that came with the fetched page (each server response has a fresh one).
195
- for (const sc of cur.querySelectorAll('script')) {
196
- if (sc.type && !/^(module|text\/javascript)$/.test(sc.type)) continue
197
- const n = document.createElement('script')
198
- for (const a of sc.attributes) if (a.name !== 'nonce') n.setAttribute(a.name, a.value)
199
- if (nonce) (n.nonce = nonce), n.setAttribute('nonce', nonce)
200
- n.textContent = sc.textContent
201
- sc.replaceWith(n)
202
- }
203
- // QA #291: the view's mount script stores its dispose (`e.$d = mount(...)`) after the swap, since module scripts
204
- // run later; until then the region shows the server's HTML with no handlers. Clicks and submits there are held and
205
- // replayed on the mounted view, by position, so the first click after a navigation is never lost.
206
- if (!cur.$d && [...cur.querySelectorAll('script[type=module]')].some(sc => /\$d\s*=/.test(sc.textContent))) hold(cur)
207
- for (const a of next.attributes || []) cur.setAttribute(a.name, a.value)
208
- if (doc.title) document.title = doc.title
209
- // SEO (§32, S04): the route owns description, og:*, twitter:*, robots, JSON-LD, canonical, hreflang and Markdown
210
- // alternates, prev/next and its route params (brain-params, read by route()): the current page's go, the next
211
- // page's come (none when it has none). Site links (icons, stylesheets, the RSS feed) and the region's own tags stay.
212
- const tags = (r, skip) =>
213
- [...((r && r.querySelectorAll && r.querySelectorAll('meta,script,link')) || [])].filter(e => {
214
- for (let p = e.parentNode; p; p = p.parentNode) if (p === skip) return false
215
- const a = n => e.getAttribute(n) || '', t = e.tagName
216
- return t == 'SCRIPT' ? a('type') == 'application/ld+json'
217
- : t == 'LINK' ? /^(canonical|prev|next)$/.test(a('rel')) || (a('rel') == 'alternate' && (a('hreflang') || a('type') == 'text/markdown'))
218
- : /^(og:|twitter:|robots$|description$|brain-params$)/.test(a('property') || a('name'))
219
- })
220
- if (document.head) {
192
+ const r = (await ahead.take(url)) || (await fetch(url, { headers: { 'x-brain-nav': '1' }, signal: ctl.signal }))
193
+ if (my !== seq) return
194
+ const hd = k => r.headers?.get?.(k), b = hd('x-brain-build')
195
+ if (b && build() && b !== build()) return hard(url) // a new deploy: load it whole
196
+ if (!r.ok || !/html/.test(hd('content-type') || 'html')) throw Error(r.status)
197
+ const doc = parse(await r.text())
198
+ if (my !== seq) return // a newer navigation won
199
+ // another language (§37): a full load brings its own dictionaries, `lang` and `dir`; locales never mix in memory
200
+ const lang = d => d?.documentElement?.getAttribute('lang')
201
+ if (lang(doc) && lang(document) && lang(doc) !== lang(document)) return hard(url)
202
+ if (r.redirected && r.url && r.url !== new URL(url, location.href).href) history.replaceState(history.state, '', r.url)
203
+ const cur = document.querySelector(region), next = doc.querySelector(region)
204
+ if (!cur || !next) return hard(url)
205
+ // DOM, head and active links change in one callback (one frame); a newer navigation that started meanwhile skips it
206
+ const swap = () => {
207
+ if (my !== seq) return
208
+ dispose(cur)
209
+ cur.replaceChildren(...[...next.childNodes].map(adopt))
210
+ // Scripts parsed by DOMParser never run: the region's own scripts (the view's mount, `e.$d = mount(...)`) are
211
+ // recreated so they execute. Already-imported modules come from the module map: their state survives.
212
+ // The page's CSP only trusts its own nonce: a recreated script carries the current page's nonce, never the one
213
+ // that came with the fetched page (each server response has a fresh one).
214
+ for (const sc of cur.querySelectorAll('script')) {
215
+ if (sc.type && !/^(module|text\/javascript)$/.test(sc.type)) continue
216
+ const n = document.createElement('script')
217
+ for (const a of sc.attributes) if (a.name !== 'nonce') n.setAttribute(a.name, a.value)
218
+ if (nonce) n.setAttribute('nonce', nonce)
219
+ n.textContent = sc.textContent
220
+ sc.replaceWith(n)
221
+ }
222
+ // QA #291: the view's mount script stores its dispose (`e.$d = mount(...)`) after the swap, since module scripts
223
+ // run later; until then the region shows the server's HTML with no handlers. Clicks and submits there are held and
224
+ // replayed on the mounted view, by position, so the first click after a navigation is never lost.
225
+ if (!cur.$d && [...cur.querySelectorAll('script[type=module]')].some(sc => /\$d\s*=/.test(sc.textContent))) hold(cur)
226
+ for (const a of next.attributes) cur.setAttribute(a.name, a.value)
227
+ if (doc.title) document.title = doc.title
228
+ // SEO (§32, S04): the route owns description, og:*, twitter:*, robots, JSON-LD, canonical, hreflang and Markdown
229
+ // alternates, prev/next and its route params (brain-params, read by route()): the current page's go, the next
230
+ // page's come (none when it has none). Site links (icons, stylesheets, the RSS feed) and the region's own tags stay.
231
+ const tags = (r, skip) =>
232
+ [...(r.querySelectorAll?.('meta,script,link') || [])].filter(e => {
233
+ for (let p = e.parentNode; p; p = p.parentNode) if (p === skip) return false
234
+ const a = n => e.getAttribute(n) || '', t = e.tagName
235
+ return t == 'SCRIPT' ? a('type') == 'application/ld+json'
236
+ : t == 'LINK' ? /^(canonical|prev|next)$/.test(a('rel')) || (a('rel') == 'alternate' && (a('hreflang') || a('type') == 'text/markdown'))
237
+ : /^(og:|twitter:|robots$|description$|brain-params$)/.test(a('property') || a('name'))
238
+ })
221
239
  for (const e of tags(document.head)) e.remove()
222
- for (const e of tags(doc.head || doc, next)) document.head.appendChild(document.adoptNode ? document.adoptNode(e) : e)
240
+ for (const e of tags(doc.head || doc, next)) document.head.appendChild(adopt(e))
241
+ routed()
242
+ current()
243
+ document.getElementById('brain-next')?.remove()
244
+ const plan = doc.querySelector('#brain-next')
245
+ if (plan) document.body.appendChild(adopt(plan))
246
+ ahead.scan()
223
247
  }
224
- routed()
225
- current()
226
- const oldPlan = document.getElementById('brain-next'), newPlan = doc.querySelector('#brain-next')
227
- if (oldPlan) oldPlan.remove()
228
- if (newPlan) document.body.appendChild(document.adoptNode ? document.adoptNode(newPlan) : newPlan)
229
- ahead.scan()
248
+ // View Transition when the browser has it (an app turns it off under prefers-reduced-motion with CSS, §19)
249
+ if (document.startViewTransition) {
250
+ const vt = document.startViewTransition(swap)
251
+ vt.ready.catch(() => {}) // rejects when a faster navigation skips this transition: expected, not an error
252
+ await vt.updateCallbackDone
253
+ } else swap()
254
+ return my === seq
255
+ } catch {
256
+ // a failed fetch, a parse or swap that threw: never a half-swapped page, the browser loads it whole
257
+ my === seq && hard(url)
258
+ } finally {
259
+ slow.done = 1
260
+ busy(my, 0)
230
261
  }
231
- if (document.startViewTransition) {
232
- const vt = document.startViewTransition(swap)
233
- vt.ready.catch(() => {}) // rejects when a faster navigation skips this transition: expected, not an error
234
- await vt.updateCallbackDone
235
- } else swap()
262
+ }
263
+ // After a swap, for assistive technology (§19): focus moves to the URL's target, else the page's h1, else the region,
264
+ // unless the new page already holds focus; #brain-announce (polite, created at start) reads the new title.
265
+ const place = (u, lost) => {
266
+ const m = document.querySelector(region), a = document.activeElement
267
+ if (!m || (a && a !== document.body && a !== m && (lost || m.contains(a)))) return
268
+ const id = new URL(u, location.href).hash.slice(1), h = (id && document.getElementById(id)) || m.querySelector('h1') || m
269
+ h.hasAttribute('tabindex') || h.setAttribute('tabindex', '-1')
270
+ h.focus({ preventScroll: true })
271
+ }
272
+ const say = t => {
273
+ let a = document.getElementById('brain-announce')
274
+ if (!a) (a = fixed('div', 'brain-announce', 'clip-path:inset(50%)')).setAttribute('aria-live', 'polite')
275
+ a.textContent = t
276
+ }
277
+ say('')
278
+ const landed = u => {
279
+ place(u)
280
+ say(document.title)
281
+ const m = document.querySelector(region)
282
+ if (m?.$hold) m.$mounted = () => place(u, 1) // mounting replaces the server's nodes: a focus lost there comes back
236
283
  }
237
284
  // a full load must not be intercepted again (Navigation API): `through` lets that one navigation pass
238
285
  const hard = url => ((through = new URL(url, location.href).href), (o.hard || (u => location.assign(u)))(url))
@@ -240,7 +287,7 @@ export function router(o = {}) {
240
287
  try {
241
288
  const x = new URL(u, location.href), b = base()
242
289
  // same origin and inside the app's base path (§23 #148); anything else is a full load
243
- return x.origin === location.origin && (x.pathname + '/').startsWith(b) && !(o.skip && o.skip(x)) && (prefetch || !fullLoad.has(x.pathname.replace(/\/+$/, '') || '/'))
290
+ return x.origin === location.origin && (x.pathname + '/').startsWith(b) && !o.skip?.(x) && (prefetch || !fullLoad.has(x.pathname.replace(/\/+$/, '') || '/'))
244
291
  } catch {
245
292
  return false
246
293
  }
@@ -253,11 +300,13 @@ export function router(o = {}) {
253
300
  if (e.navigationType === 'reload') return // a reload is always a real full load (e.g. after a deploy deleted a chunk)
254
301
  if (!e.canIntercept || e.hashChange || e.downloadRequest || e.formData || !same(e.destination.url)) return
255
302
  if (e.cancelable && !leave()) return e.preventDefault()
256
- const to = e.destination.url, from = nav.currentEntry && nav.currentEntry.url
257
- if (G.__brainURLOnly?.(from || location.href, to)) return e.intercept({ handler: () => G.__brainURLRestore?.() })
258
- if (!gs.length) return e.intercept({ handler: () => load(to) }) // scroll + focus reset handled by the browser
303
+ const to = e.destination.url, from = nav.currentEntry?.url
304
+ if (G.__brainURLOnly?.(from || location.href, to)) return e.intercept({ focusReset: 'manual', handler: () => G.__brainURLRestore?.() })
305
+ const shown = u => load(u).then(ok => ok && landed(u)) // the browser restores scroll; focus is ours (it would reset to body)
306
+ if (!gs.length) return e.intercept({ focusReset: 'manual', handler: () => shown(to) })
259
307
  let g
260
308
  e.intercept({
309
+ focusReset: 'manual',
261
310
  // Chrome 141+: decided before the URL commits (cancel = the address bar never changes; native redirect).
262
311
  async precommitHandler(c) {
263
312
  g = await check(to)
@@ -266,7 +315,7 @@ export function router(o = {}) {
266
315
  },
267
316
  async handler() {
268
317
  if (g === undefined) g = await check(to) // no precommit support: the URL already committed
269
- if (g === true) return load(nav.currentEntry ? nav.currentEntry.url : to)
318
+ if (g === true) return shown(nav.currentEntry ? nav.currentEntry.url : to)
270
319
  if (g === null) return hard(to)
271
320
  nav.navigate(g === false ? from : new URL(app(g), location.href).href, { history: 'replace' })
272
321
  },
@@ -277,7 +326,7 @@ export function router(o = {}) {
277
326
  return (active = {
278
327
  go: (u, how) => nav.navigate(app(u), how === 'replace' ? { history: 'replace' } : undefined),
279
328
  check,
280
- stop: () => ((active = null), ahead.stop(), nav.removeEventListener('navigate', h), sw && sw.removeEventListener && sw.removeEventListener('message', onMsg)),
329
+ stop: () => (quit(), nav.removeEventListener('navigate', h)),
281
330
  })
282
331
  }
283
332
 
@@ -292,12 +341,12 @@ export function router(o = {}) {
292
341
  if (g === false) return push || history.pushState(history.state, '', here) // back/forward blocked: stay
293
342
  if (g !== true) return go(new URL(app(g), location.href).href, push, hops + 1)
294
343
  if (push) save(), history[push === 'replace' ? 'replaceState' : 'pushState']({ y: 0 }, '', u) // push: true | 'replace' | false (back/forward)
295
- await load(u)
344
+ if (!(await load(u))) return // a newer navigation (or a full load) owns scroll and focus now
296
345
  here = location.href
297
346
  const hash = new URL(u, location.href).hash, t = hash && document.getElementById(hash.slice(1))
298
347
  if (t) t.scrollIntoView()
299
- else G.scrollTo && G.scrollTo(0, push ? 0 : (history.state && history.state.y) || 0)
300
- focusMain()
348
+ else G.scrollTo?.(0, push ? 0 : history.state?.y || 0)
349
+ landed(u)
301
350
  }
302
351
  const click = e => {
303
352
  if (e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
@@ -313,20 +362,13 @@ export function router(o = {}) {
313
362
  if (G.__brainURLOnly?.(here, location.href)) { here = location.href; G.__brainURLRestore?.(); return }
314
363
  return go(location.href, false)
315
364
  }
316
- const focusMain = () => {
317
- const m = document.querySelector(region), h = m && (m.querySelector('h1') || m)
318
- if (h && h.focus) {
319
- if (!h.hasAttribute('tabindex')) h.setAttribute('tabindex', '-1')
320
- h.focus({ preventScroll: true }) // screen readers announce the new view
321
- }
322
- }
323
365
  document.addEventListener('click', click)
324
- G.addEventListener && G.addEventListener('popstate', pop)
366
+ G.addEventListener?.('popstate', pop)
325
367
  ahead.scan()
326
368
  return (active = {
327
369
  go: (u, push = true) => go(app(u), push),
328
370
  check,
329
- stop: () => ((active = null), ahead.stop(), document.removeEventListener('click', click), G.removeEventListener && G.removeEventListener('popstate', pop)),
371
+ stop: () => (quit(), document.removeEventListener('click', click), G.removeEventListener?.('popstate', pop)),
330
372
  })
331
373
  }
332
374
 
@@ -349,11 +391,11 @@ const FILE = /\.(?!html?$)[a-z0-9]{2,5}$/i
349
391
  const SECRET = /(^|[?&])(token|code|key|sig|signature|auth|otp|state)=/i
350
392
  export function predict(o = {}, same = () => false) {
351
393
  const off = { scan() {}, stop() {}, take: async () => null }
352
- const meta = G.document && document.querySelector && document.querySelector('meta[name=brain-prefetch]')
394
+ const meta = G.document?.querySelector?.('meta[name=brain-prefetch]')
353
395
  if (o.prefetch === false || (meta && /^(off|false|0)$/i.test(meta.getAttribute('content') || '')) || !G.document || !G.fetch) return off
354
- const net = G.navigator && navigator.connection
396
+ const net = G.navigator?.connection
355
397
  const thin = () => !!net && (net.saveData || /(^|-)2g$/.test(net.effectiveType || ''))
356
- const cache = new Map(), queue = [], pre = new Map(), running = new Map(), pending = new Map(), planned = new Map()
398
+ const cache = new Map(), rank = new Map(), queue = [], pre = new Map(), running = new Map(), pending = new Map(), planned = new Map()
357
399
  let seen = new WeakSet(), busy = 0, count = 0, io = null, idle = 0, epoch = 0, dead = false, bytes = 0, worker = null, serial = 0
358
400
  const key = h => {
359
401
  try { const u = new URL(h, location.href); u.hash = ''; return u.href } catch { return null }
@@ -364,43 +406,41 @@ export function predict(o = {}, same = () => false) {
364
406
  return !NOPE.test(u.pathname) && !FILE.test(u.pathname) && !SECRET.test(u.search)
365
407
  }
366
408
  const ok = a => {
367
- if (!a || !a.getAttribute || !a.getAttribute('href') || a.getAttribute('target') || a.hasAttribute('download')) return null
368
- for (let n = a; n && n.getAttribute; n = n.parentNode) if (n.getAttribute('data-prefetch') === 'false') return null
409
+ if (!a?.getAttribute?.('href') || a.getAttribute('target') || a.hasAttribute('download')) return null
410
+ for (let n = a; n?.getAttribute; n = n.parentNode) if (n.getAttribute('data-prefetch') === 'false') return null
369
411
  const k = key(a.href || a.getAttribute('href'))
370
412
  return safe(k) ? k : null
371
413
  }
372
414
  const warm = list => {
373
- if (!document.head || dead) return
415
+ if (dead) return
374
416
  let n = 0
375
417
  for (const href of list) {
376
418
  const u = key(href)
377
- if (!u || pre.has(u) || !same(u) || !new URL(u).pathname.includes('/_b/') || ++n > 8) continue
419
+ if (!u || pre.has(u) || pre.size > 47 || !same(u) || !new URL(u).pathname.includes('/_b/') || ++n > 8) continue
378
420
  const l = document.createElement('link')
379
421
  l.setAttribute('rel', /\.(js|jsbrain)(\?|$)/.test(u) ? 'modulepreload' : 'prefetch')
380
422
  l.setAttribute('href', u)
381
423
  l.setAttribute('fetchpriority', 'low')
382
424
  document.head.appendChild(l)
383
425
  pre.set(u, l)
384
- while (pre.size > 48) { const [k, node] = pre.entries().next().value; node.remove(); pre.delete(k) }
385
426
  }
386
427
  }
387
428
  const remove = k => { const c = cache.get(k); if (c) bytes -= c.bytes || 0; cache.delete(k) }
388
429
  const prune = () => {
389
- for (const [k, c] of cache) if (Date.now() - c.at > FRESH) remove(k)
390
430
  while (cache.size > MAX_KEPT || bytes > 4 * 1024 * 1024) remove(cache.keys().next().value)
391
431
  }
392
432
  const crashed = () => {
393
- worker && worker.terminate(); worker = null
433
+ worker?.terminate(); worker = null
394
434
  for (const p of pending.values()) { clearTimeout(p.timer); p.resolve(null) }
395
435
  pending.clear()
396
436
  }
397
- if (G.Worker && typeof o.prefetchWorker === 'string') {
437
+ if (G.Worker && o.prefetchWorker) {
398
438
  try {
399
439
  const url = new URL(o.prefetchWorker, location.href)
400
440
  if (url.origin === location.origin) {
401
441
  worker = new Worker(url.href, { type: 'module' })
402
442
  worker.onmessage = e => {
403
- const m = e.data, p = m && pending.get(m.id)
443
+ const m = e.data, p = pending.get(m?.id)
404
444
  if (!p) return
405
445
  clearTimeout(p.timer); pending.delete(m.id); p.resolve(m.ok ? m : null)
406
446
  }
@@ -412,7 +452,7 @@ export function predict(o = {}, same = () => false) {
412
452
  const background = (k, controller) => {
413
453
  if (worker) return new Promise(resolve => {
414
454
  const id = ++serial
415
- const done = () => { const p = pending.get(id); if (p) { clearTimeout(p.timer); pending.delete(id); resolve(null); worker && worker.postMessage({ id, cancel: true }) } }
455
+ const done = () => { const p = pending.get(id); if (p) { clearTimeout(p.timer); pending.delete(id); resolve(null); worker?.postMessage({ id, cancel: true }) } }
416
456
  const timer = setTimeout(done, 6500)
417
457
  pending.set(id, { resolve, timer })
418
458
  controller.signal.addEventListener('abort', done, { once: true })
@@ -422,19 +462,20 @@ export function predict(o = {}, same = () => false) {
422
462
  }
423
463
 
424
464
  const get = k => {
425
- const own = epoch, controller = new AbortController(), at = Date.now()
465
+ const controller = new AbortController()
426
466
  running.set(k, controller)
427
467
  const timer = setTimeout(() => controller.abort(), 6000)
428
- const c = { at, epoch: own, bytes: 0, p: null }
468
+ const c = { at: Date.now(), epoch, bytes: 0, t: timer }
429
469
  c.p = background(k, controller).then(m => {
430
- if (!m || dead || own !== epoch || controller.signal.aborted) { if (cache.get(k) === c) remove(k); return null }
470
+ if (!m || dead || c.epoch !== epoch || controller.signal.aborted) { if (cache.get(k) === c) remove(k); return null }
431
471
  const headers = Array.isArray(m.headers) ? new Headers(m.headers) : m.headers
432
472
  const cc = headers.get('cache-control') || '', freshness = /(?:^|,)\s*(?:s-maxage|max-age)=(\d+)/i.exec(cc)
433
473
  if (m.assets) warm(m.assets)
434
474
  if (planned.has(k)) warm(planned.get(k))
435
- // Personalized and dynamically revalidated HTML always goes back through the server on navigation.
475
+ // Personalized and dynamically revalidated HTML always goes back through the server on navigation, unless that
476
+ // navigation was already waiting for this very request (taken in flight: as fresh as a new one).
436
477
  // Speculation still warms its immutable code/assets; it never reuses another tab's prior session data.
437
- if (/private|no-store/i.test(cc) || /no-cache/i.test(cc) && headers.get('x-brain-static') !== '1') { if (cache.get(k) === c) remove(k); return null }
478
+ if (!c.taken && (/private|no-store/i.test(cc) || /no-cache/i.test(cc) && headers.get('x-brain-static') !== '1')) { if (cache.get(k) === c) remove(k); return null }
438
479
  // Transient page memory belongs to this tab and write epoch, never to CacheStorage or the worker.
439
480
  c.bytes = m.text.length * 2
440
481
  if (cache.get(k) === c) bytes += c.bytes
@@ -445,7 +486,7 @@ export function predict(o = {}, same = () => false) {
445
486
  return c.p
446
487
  }
447
488
  const pump = () => {
448
- if (dead || document.hidden || net && net.saveData) return
489
+ if (dead || document.hidden || net?.saveData) return
449
490
  while (busy < PARALLEL && queue.length) {
450
491
  const k = queue.shift()
451
492
  if (cache.has(k)) continue
@@ -454,16 +495,19 @@ export function predict(o = {}, same = () => false) {
454
495
  }
455
496
  }
456
497
  const later = f => G.requestIdleCallback ? G.requestIdleCallback(f, { timeout: 2000 }) : setTimeout(f, 200)
457
- const cancelIdle = () => { if (idle) { G.cancelIdleCallback ? G.cancelIdleCallback(idle) : clearTimeout(idle); idle = 0 } }
458
- const want = (k, now) => {
459
- if (!k || dead || cache.has(k) || count >= (now ? MAX_KEPT : MAX_PAGE)) return
498
+ // The queue is ordered by rank, lower first: intent, then main content by position, the compiler's plan, then footer
499
+ // and aside. A click never waits here: take() or its own fetch.
500
+ const want = (k, now, at = 1e6) => {
501
+ if (!k || dead || cache.has(k) || count >= (now ? MAX_KEPT : MAX_PAGE) || (!now && !same(k, false))) return // only intent warms a full-load route
460
502
  const i = queue.indexOf(k)
461
503
  if (i >= 0) queue.splice(i, 1); else count++
462
- if (now) { queue.unshift(k); pump() }
463
- else { queue.push(k); if (!idle) idle = later(() => { idle = 0; pump() }) }
504
+ rank.set(k, now ? -1 : at)
505
+ queue.push(k)
506
+ queue.sort((a, b) => rank.get(a) - rank.get(b))
507
+ if (now) pump()
508
+ else if (!idle) idle = later(() => { idle = 0; pump() })
464
509
  }
465
510
  const intent = e => {
466
- if (net && net.saveData) return
467
511
  let a = e.target
468
512
  while (a && a.tagName !== 'A') a = a.parentNode
469
513
  want(ok(a), true)
@@ -471,42 +515,43 @@ export function predict(o = {}, same = () => false) {
471
515
  const evs = ['pointerover', 'focusin', 'touchstart']
472
516
  for (const t of evs) document.addEventListener(t, intent, { passive: true, capture: true })
473
517
  const forget = () => {
474
- epoch++; cancelIdle(); queue.length = 0; cache.clear(); bytes = 0; count = 0
518
+ epoch++; queue.length = 0; cache.clear(); bytes = 0; count = 0
475
519
  for (const c of running.values()) c.abort()
476
520
  running.clear()
477
521
  }
478
522
  document.addEventListener('submit', forget, { capture: true })
479
- if (G.addEventListener) G.addEventListener('brain:write', forget)
480
- const visible = () => { if (!document.hidden) pump() }
481
- document.addEventListener('visibilitychange', visible)
523
+ G.addEventListener?.('brain:write', forget)
482
524
  if (G.IntersectionObserver) io = new IntersectionObserver(es => {
483
- for (const e of es) if (e.isIntersecting) { io.unobserve(e.target); if (!thin()) want(ok(e.target), false) }
525
+ for (const { target: a, isIntersecting } of es) {
526
+ const k = ok(a), i = queue.indexOf(k)
527
+ if (isIntersecting) thin() || want(k, false, (a.closest('footer,aside') ? 1e7 : 0) + a.getBoundingClientRect().top)
528
+ else if (i >= 0) queue.splice(i, 1) // left the viewport before its turn
529
+ }
484
530
  }, { rootMargin: '200px' })
485
531
  return {
486
532
  scan() {
487
533
  if (dead) return
488
- count = 0; cancelIdle(); queue.length = 0; planned.clear(); seen = new WeakSet(); io && io.disconnect()
534
+ count = 0; queue.length = 0; planned.clear(); seen = new WeakSet(); io?.disconnect()
489
535
  const anchors = document.querySelectorAll('a[href]'), allowed = new Set()
490
536
  for (const a of anchors) { const k = ok(a); if (k) allowed.add(k); if (io && !seen.has(a)) { seen.add(a); io.observe(a) } }
491
537
  // Compiler-generated graph, validated against the current DOM: hidden/opted-out links cannot sneak in.
492
- const plan = document.getElementById && document.getElementById('brain-next')
538
+ const plan = document.getElementById('brain-next')
493
539
  if (plan && !thin()) {
494
540
  try {
495
541
  const map = JSON.parse(plan.textContent)
496
- if (map.v === 1 && Array.isArray(map.next)) for (const item of map.next.slice(0, MAX_PAGE)) {
542
+ if (map.v === 1) for (const item of map.next.slice(0, MAX_PAGE)) {
497
543
  const k = key(item.url)
498
- if (allowed.has(k) && safe(k)) { if (Array.isArray(item.assets)) planned.set(k, item.assets.slice(0, 16)); want(k, false) }
544
+ if (allowed.has(k) && safe(k)) { if (item.assets) planned.set(k, item.assets.slice(0, 16)); want(k, false) }
499
545
  }
500
546
  } catch {}
501
547
  }
502
548
  },
503
549
  stop() {
504
550
  if (dead) return
505
- forget(); dead = true; crashed(); io && io.disconnect()
551
+ forget(); dead = true; crashed(); io?.disconnect()
506
552
  for (const t of evs) document.removeEventListener(t, intent, { capture: true })
507
553
  document.removeEventListener('submit', forget, { capture: true })
508
- document.removeEventListener('visibilitychange', visible)
509
- G.removeEventListener && G.removeEventListener('brain:write', forget)
554
+ G.removeEventListener?.('brain:write', forget)
510
555
  for (const l of pre.values()) l.remove()
511
556
  pre.clear()
512
557
  },
@@ -514,11 +559,10 @@ export function predict(o = {}, same = () => false) {
514
559
  const k = key(u), c = k && cache.get(k)
515
560
  if (!c) return null
516
561
  remove(k)
517
- // A click must not inherit a slow speculative request's six-second deadline.
518
- let timer
519
- const r = await Promise.race([c.p, new Promise(resolve => { timer = setTimeout(() => resolve(null), 150) })])
520
- clearTimeout(timer)
521
- if (!r) { const controller = running.get(k); controller && controller.abort() }
562
+ // The navigation reuses the request already on its way (never a second one) and drops its speculative deadline.
563
+ clearTimeout(c.t)
564
+ c.taken = 1
565
+ const r = await c.p
522
566
  return r && !dead && c.epoch === epoch && Date.now() - c.at < r.ttl ? r : null
523
567
  },
524
568
  }
@@ -526,11 +570,14 @@ export function predict(o = {}, same = () => false) {
526
570
 
527
571
  const HOLD = ['click', 'submit']
528
572
  function hold(el) {
529
- if (el.$hold) el.$hold() // a newer navigation drops what the previous page held
530
573
  const q = []
531
574
  const keep = e => {
532
575
  const path = []
533
- for (let n = e.target; n && n !== el; n = n.parentNode) path.unshift([].indexOf.call(n.parentNode.childNodes, n))
576
+ // a link needs no mounted view: it navigates now (a real href; a `#` one waits for its handler like a button)
577
+ for (let n = e.target; n && n !== el; n = n.parentNode) {
578
+ if (n.tagName === 'A' && (n.getAttribute('href') || '#')[0] != '#') return
579
+ path.unshift([].indexOf.call(n.parentNode.childNodes, n))
580
+ }
534
581
  q.push([e, path])
535
582
  e.preventDefault()
536
583
  e.stopImmediatePropagation()
@@ -551,15 +598,13 @@ function hold(el) {
551
598
  set(v) {
552
599
  done()
553
600
  el.$d = v
601
+ el.$mounted?.()
602
+ el.$mounted = null
554
603
  for (const [e, path] of q) {
555
604
  let n = el
556
- for (const i of path) n = n && n.childNodes[i]
557
- if (n) n.dispatchEvent(new e.constructor(e.type, e))
605
+ for (const i of path) n = n?.childNodes[i]
606
+ n?.dispatchEvent(new e.constructor(e.type, e))
558
607
  }
559
608
  },
560
609
  })
561
610
  }
562
- function walkAll(n, out = []) {
563
- for (const c of n.childNodes) if (c.nodeType === 1) out.push(c), walkAll(c, out)
564
- return out
565
- }