@christianirack/brainllm-runtime 0.6.1 → 0.8.0

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