@barefootjs/router 0.14.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/src/router.ts ADDED
@@ -0,0 +1,450 @@
1
+ /**
2
+ * The router controller: event interception, programmatic navigation, prefetch.
3
+ *
4
+ * One router is active at a time (`active`). `startRouter` installs all the
5
+ * seams itself (correct by default — no opt-in step), delegates events on
6
+ * `document`/`window`, and returns a `Router` handle. A navigation swaps only
7
+ * the `[bf-region]` children, disposing the outgoing islands and re-hydrating
8
+ * the incoming ones, with last-wins semantics across overlapping navigations.
9
+ */
10
+
11
+ import { BF_REGION } from '@barefootjs/shared'
12
+ import { loadPage } from './cache.ts'
13
+ import {
14
+ captureRegionBaselines,
15
+ collectModuleScripts,
16
+ collectRegionModuleSrcs,
17
+ importRegionChildren,
18
+ isRootRegion,
19
+ loadNewModules,
20
+ parseDocument,
21
+ planRegionSwaps,
22
+ type RegionSwap,
23
+ } from './region.ts'
24
+ import { buildMorphedContent } from './morph.ts'
25
+ import {
26
+ defaultDispose,
27
+ defaultRehydrate,
28
+ hardNavigate,
29
+ pushSearchSeam,
30
+ } from './seams.ts'
31
+ import { announceNavigation, focusRegion } from './a11y.ts'
32
+ import type {
33
+ NavigateOptions,
34
+ PageSnapshot,
35
+ Router,
36
+ RouterOptions,
37
+ RouterState,
38
+ } from './types.ts'
39
+
40
+ let active: RouterState | null = null
41
+
42
+ export function startRouter(options: RouterOptions = {}): Router {
43
+ // SSR / non-DOM: a no-op handle, never throws.
44
+ if (typeof document === 'undefined' || typeof window === 'undefined') {
45
+ return { stop() {}, navigate: async () => {}, prefetch() {} }
46
+ }
47
+
48
+ // Replacing the active router is idempotent — tear the old one down first.
49
+ if (active) active.stop()
50
+
51
+ const state: RouterState = {
52
+ regionSelector: options.region ?? `[${BF_REGION}]`,
53
+ rehydrate: options.rehydrate ?? defaultRehydrate,
54
+ dispose: options.dispose ?? defaultDispose,
55
+ loadModule: options.loadModule ?? ((src) => import(src)),
56
+ // Bind through a wrapper so a default `fetch` keeps its global `this`.
57
+ fetchFn: options.fetch ?? ((input, init) => fetch(input, init)),
58
+ // Seed with modules already on the page so we never re-import them. The
59
+ // live document's srcs resolve against the current location.
60
+ loadedModules: collectModuleScripts(document, window.location.href),
61
+ prefetchEnabled: options.prefetch ?? true,
62
+ prefetchDelay: options.prefetchDelay ?? 65,
63
+ cacheFreshMs: options.cacheFreshMs ?? 15_000,
64
+ cacheStaleMs: options.cacheStaleMs ?? 60_000,
65
+ cacheCap: options.cacheCap ?? 30,
66
+ cache: new Map(),
67
+ preloaded: new Set(),
68
+ shouldIntercept: options.shouldIntercept ?? defaultShouldIntercept,
69
+ scrollToTop: options.scrollToTop ?? true,
70
+ manageFocus: options.manageFocus ?? true,
71
+ morph: options.morph ?? true,
72
+ // Seed the per-region baselines from the initial document's server render
73
+ // (captured before islands mutate the DOM). Refreshed after each navigation.
74
+ regionBaselines: captureRegionBaselines(document, options.region ?? `[${BF_REGION}]`),
75
+ currentPath: window.location.pathname,
76
+ inflight: null,
77
+ hoverTimer: null,
78
+ hoverAnchor: null,
79
+ stop: () => {},
80
+ }
81
+
82
+ document.addEventListener('click', onClick)
83
+ window.addEventListener('popstate', onPopState)
84
+ if (state.prefetchEnabled) {
85
+ document.addEventListener('mouseover', onPointerOver)
86
+ document.addEventListener('mouseout', onPointerOut)
87
+ document.addEventListener('focusin', onFocusIn)
88
+ document.addEventListener('pointerdown', onPointerDown)
89
+ }
90
+ // Anchor the entry so a later back-navigation lands here, preserving any
91
+ // existing history.state a framework/scroll lib may have stored.
92
+ commitHistory('replace', window.location.href)
93
+
94
+ state.stop = () => {
95
+ document.removeEventListener('click', onClick)
96
+ window.removeEventListener('popstate', onPopState)
97
+ document.removeEventListener('mouseover', onPointerOver)
98
+ document.removeEventListener('mouseout', onPointerOut)
99
+ document.removeEventListener('focusin', onFocusIn)
100
+ document.removeEventListener('pointerdown', onPointerDown)
101
+ clearHoverTimer(state)
102
+ state.inflight?.abort()
103
+ if (active === state) active = null
104
+ }
105
+
106
+ active = state
107
+ return { stop: state.stop, navigate, prefetch: (url) => prefetch(url) }
108
+ }
109
+
110
+ /** Collect nodes into a fragment (the non-morph swap path). */
111
+ function toFragment(nodes: Node[]): DocumentFragment {
112
+ const frag = document.createDocumentFragment()
113
+ for (const node of nodes) frag.appendChild(node)
114
+ return frag
115
+ }
116
+
117
+ // --- History --------------------------------------------------------------
118
+
119
+ /**
120
+ * Commit a history entry while **preserving existing `history.state`**
121
+ * (spec step 6). `replaceState` overwrites the current entry's state, so we
122
+ * merge rather than clobber — a scroll-restoration lib or framework state
123
+ * survives a router-driven replace. A `push` starts a fresh entry (there is no
124
+ * prior state to keep), tagged so `popstate` can recognise router-owned entries.
125
+ */
126
+ function commitHistory(mode: 'push' | 'replace', url: string): void {
127
+ if (mode === 'push') {
128
+ window.history.pushState({ bfRouter: true }, '', url)
129
+ } else {
130
+ const prev = (window.history.state ?? {}) as Record<string, unknown>
131
+ window.history.replaceState({ ...prev, bfRouter: true }, '', url)
132
+ }
133
+ }
134
+
135
+ // --- Click / navigation interception --------------------------------------
136
+
137
+ function onClick(event: MouseEvent): void {
138
+ if (!active) return
139
+ if (event.defaultPrevented) return
140
+ // Only plain left-clicks — let the browser own modified / middle clicks.
141
+ if (event.button !== 0) return
142
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
143
+
144
+ const target = event.target as Element | null
145
+ const anchor = target?.closest?.('a') as HTMLAnchorElement | null
146
+ if (!anchor || !anchor.getAttribute('href')) return
147
+ if (!active.shouldIntercept(anchor, event)) return
148
+
149
+ event.preventDefault()
150
+ void navigate(anchor.href)
151
+ }
152
+
153
+ function defaultShouldIntercept(anchor: HTMLAnchorElement): boolean {
154
+ if (anchor.dataset.bfRouter === 'false') return false
155
+ if (anchor.hasAttribute('download')) return false
156
+
157
+ const linkTarget = anchor.getAttribute('target')
158
+ if (linkTarget && linkTarget !== '_self') return false
159
+
160
+ const rel = anchor.getAttribute('rel') ?? ''
161
+ if (rel.split(/\s+/).includes('external')) return false
162
+
163
+ let url: URL
164
+ try {
165
+ url = new URL(anchor.href, window.location.href)
166
+ } catch {
167
+ return false
168
+ }
169
+ if (url.origin !== window.location.origin) return false
170
+
171
+ // Same page, hash-only change → let the browser scroll to the anchor.
172
+ if (
173
+ url.pathname === window.location.pathname &&
174
+ url.search === window.location.search &&
175
+ url.hash
176
+ ) {
177
+ return false
178
+ }
179
+
180
+ return true
181
+ }
182
+
183
+ function onPopState(): void {
184
+ if (!active) return
185
+ // A query-only back/forward on the same route doesn't need a swap: push the
186
+ // new query into the env signal (if a consumer registered the seam) and let
187
+ // islands react fine-grained. A pathname change does need a swap, but the
188
+ // browser already moved history, so don't write it again (`history: false`).
189
+ if (
190
+ window.location.pathname === active.currentPath &&
191
+ pushSearchSeam(window.location.search)
192
+ ) {
193
+ return
194
+ }
195
+ void navigate(window.location.href, { history: false })
196
+ }
197
+
198
+ // --- Core navigation ------------------------------------------------------
199
+
200
+ export async function navigate(url: string, options: NavigateOptions = {}): Promise<void> {
201
+ // SSR / non-DOM: a no-op. The bare `navigate` export is documented SSR-safe,
202
+ // but on the server `active` is null and the fall-through to `hardNavigate`
203
+ // would touch `window` and throw — guard before that.
204
+ if (typeof window === 'undefined' || typeof document === 'undefined') return
205
+ const mode = options.history ?? 'push'
206
+ const state = active
207
+ if (!state) {
208
+ hardNavigate(url)
209
+ return
210
+ }
211
+
212
+ const target = new URL(url, window.location.href)
213
+ // Different origin → hand back to the browser.
214
+ if (target.origin !== window.location.origin) {
215
+ hardNavigate(url)
216
+ return
217
+ }
218
+
219
+ // Same-route, query-only change → if a `searchParams()` consumer registered
220
+ // the seam, update the signal + URL but DON'T swap (islands react fine-
221
+ // grained). Otherwise fall through to a normal swap (legacy behaviour).
222
+ if (
223
+ target.pathname === window.location.pathname &&
224
+ target.search !== window.location.search &&
225
+ pushSearchSeam(target.search)
226
+ ) {
227
+ state.inflight?.abort()
228
+ if (mode === 'push') commitHistory('push', target.href)
229
+ else if (mode === 'replace') commitHistory('replace', target.href)
230
+ return
231
+ }
232
+
233
+ // Supersede any in-flight navigation. `inflight` doubles as a "still current"
234
+ // flag: a newer navigation aborts it, and the abort checks after each `await`
235
+ // let the latest navigation win. The fetch itself isn't cancelled — it still
236
+ // populates the shared cache.
237
+ state.inflight?.abort()
238
+ const controller = new AbortController()
239
+ state.inflight = controller
240
+
241
+ try {
242
+ const snap = await loadPage(state, target.href)
243
+ if (controller.signal.aborted) return
244
+ if (!snap) {
245
+ hardNavigate(target.href)
246
+ return
247
+ }
248
+ const finalUrl = snap.finalUrl
249
+
250
+ // Parse the incoming page once. Module srcs are collected from the whole
251
+ // document and resolved against the response's final URL (after redirects),
252
+ // not the current location.
253
+ const incomingDoc = parseDocument(snap.html)
254
+ const title = incomingDoc.querySelector('title')?.textContent ?? null
255
+ const moduleSrcs = [...collectModuleScripts(incomingDoc, finalUrl)]
256
+
257
+ // Decide the swap point(s). When the compiler-derived region ids line up
258
+ // across both documents, swap only the deepest regions whose owned content
259
+ // differs (nested/sibling, spec v2); otherwise fall back to the single
260
+ // broadest region (v0).
261
+ const plan = planRegionSwaps(document, incomingDoc, state.regionSelector, state.regionBaselines)
262
+ let targets: RegionSwap[]
263
+ if (plan.mode === 'regions') {
264
+ targets = plan.targets
265
+ } else {
266
+ // Ids diverge or collide. Swap the broadest region only if it is a true
267
+ // root containing every other region (one swap rebuilds them all — the v0
268
+ // single-region behaviour). If the regions are siblings, a single swap
269
+ // would half-update the page, so hard-navigate instead (never worse than
270
+ // an MPA).
271
+ const current = document.querySelector(state.regionSelector)
272
+ const incoming = incomingDoc.querySelector(state.regionSelector)
273
+ if (!current || !incoming || !isRootRegion(current, state.regionSelector)) {
274
+ hardNavigate(finalUrl)
275
+ return
276
+ }
277
+ targets = [{ current, incoming }]
278
+ }
279
+
280
+ // Swap every target synchronously, before any `await`. A morph-preserved
281
+ // node therefore travels current → fragment → current without ever being
282
+ // detached across the dispose `await`: a navigation that supersedes this
283
+ // one still finds it under its region (last-wins safe), and an abort/throw
284
+ // during dispose can't leave it orphaned. Each `outgoing` is a shallow clone
285
+ // of its region element (tag, attributes, id, classes — e.g.
286
+ // `main[bf-region]`) so a custom `dispose` sees the same shell it ran
287
+ // against on first mount, not a bare `<div>`. With no permanent nodes and a
288
+ // single region this is exactly the v0 `replaceChildren` swap.
289
+ const swapped: { region: Element; outgoing: Element }[] = []
290
+ for (const { current, incoming } of targets) {
291
+ const nodes = importRegionChildren(incoming)
292
+ const fragment = state.morph ? buildMorphedContent(current, nodes) : toFragment(nodes)
293
+ const outgoing = current.cloneNode(false) as Element
294
+ // An explicit drain (rather than `append(...current.childNodes)`) is
295
+ // unambiguous about not skipping nodes while the live `childNodes` shrinks.
296
+ while (current.firstChild) outgoing.append(current.firstChild)
297
+ current.replaceChildren(fragment)
298
+ swapped.push({ region: current, outgoing })
299
+ }
300
+ if (title !== null) document.title = title
301
+
302
+ // Refresh the per-region baselines to the server render now displayed: from
303
+ // the incoming keys (matched regions), else recaptured from the live DOM
304
+ // (the broadest fallback just inserted fresh server content). Done right
305
+ // after the synchronous swaps so a superseded navigation that bails below
306
+ // still leaves baselines consistent with the committed DOM.
307
+ if (plan.mode === 'regions') {
308
+ for (const [id, key] of plan.incomingKeys) state.regionBaselines.set(id, key)
309
+ } else {
310
+ state.regionBaselines = captureRegionBaselines(document, state.regionSelector)
311
+ }
312
+
313
+ // Dispose the outgoing islands, sequentially in document order. `dispose` is
314
+ // user-overridable and may do global teardown or assume single-call
315
+ // ordering, so we never run several in parallel (the region count is small).
316
+ // Each `outgoing` is already detached and the swaps are committed, so a
317
+ // superseded navigation just skips the remaining work below — it never has
318
+ // to undo a DOM mutation. With `morph`, any matched `[data-bf-permanent]`
319
+ // node was moved into the new tree above, so it isn't here; with
320
+ // `morph: false` the permanent nodes stay in `outgoing` and are disposed.
321
+ for (const { outgoing } of swapped) await state.dispose(outgoing)
322
+ if (controller.signal.aborted) return
323
+
324
+ // Register any island modules this response introduced before hydrating.
325
+ await loadNewModules(state, moduleSrcs)
326
+ if (controller.signal.aborted) return
327
+
328
+ if (mode === 'push') commitHistory('push', finalUrl)
329
+ else if (mode === 'replace') commitHistory('replace', finalUrl)
330
+
331
+ const committed = new URL(finalUrl, window.location.href)
332
+ state.currentPath = committed.pathname
333
+ pushSearchSeam(committed.search)
334
+
335
+ if (state.scrollToTop) window.scrollTo(0, 0)
336
+
337
+ // Re-hydrate the freshly inserted islands (subtree-scoped, per region),
338
+ // sequentially in document order — like `dispose`, `rehydrate` is
339
+ // user-overridable and may touch shared global state, so parallel runs are
340
+ // avoided (the region count is small).
341
+ for (const { region } of swapped) await state.rehydrate(region)
342
+ // `rehydrate` may await (a dynamic import); a newer navigation could have
343
+ // superseded us in the meantime — don't run a11y side effects on what is
344
+ // now stale content.
345
+ if (controller.signal.aborted) return
346
+
347
+ // Accessibility: move focus into the first swapped region (the broadest /
348
+ // topmost in document order) and announce the route. A navigation with no
349
+ // changed region (targets empty) still committed history + title above.
350
+ if (state.manageFocus && swapped.length > 0) {
351
+ focusRegion(swapped[0].region)
352
+ announceNavigation(title)
353
+ }
354
+ } finally {
355
+ if (state.inflight === controller) state.inflight = null
356
+ }
357
+ }
358
+
359
+ // --- Prefetch -------------------------------------------------------------
360
+
361
+ function prefetch(url: string): void {
362
+ const state = active
363
+ if (!state) return
364
+ let target: URL
365
+ try {
366
+ target = new URL(url, window.location.href)
367
+ } catch {
368
+ return
369
+ }
370
+ if (target.origin !== window.location.origin) return
371
+ if (target.href === window.location.href) return // current page
372
+
373
+ void loadPage(state, target.href).then((snap) => {
374
+ if (snap) preloadModules(state, snap)
375
+ })
376
+ }
377
+
378
+ function preloadModules(state: RouterState, snap: PageSnapshot): void {
379
+ // Parse once and read only the module srcs (no region-node import), resolved
380
+ // against the fetched page's final URL.
381
+ const srcs = collectRegionModuleSrcs(snap.html, state.regionSelector, snap.finalUrl) ?? []
382
+ for (const src of srcs) {
383
+ if (state.loadedModules.has(src) || state.preloaded.has(src)) continue
384
+ state.preloaded.add(src)
385
+ const link = document.createElement('link')
386
+ link.rel = 'modulepreload'
387
+ link.href = src
388
+ document.head.appendChild(link)
389
+ }
390
+ }
391
+
392
+ // The dwell timer lives on `RouterState` (per instance), not at module scope —
393
+ // so two routers don't share one timer and tests are isolated without manual
394
+ // global resets.
395
+ function clearHoverTimer(state: RouterState): void {
396
+ if (state.hoverTimer !== null) {
397
+ clearTimeout(state.hoverTimer)
398
+ state.hoverTimer = null
399
+ }
400
+ state.hoverAnchor = null
401
+ }
402
+
403
+ function prefetchableAnchor(event: Event): HTMLAnchorElement | null {
404
+ const state = active
405
+ if (!state) return null
406
+ const anchor = (event.target as Element | null)?.closest?.('a') as HTMLAnchorElement | null
407
+ if (!anchor || !anchor.getAttribute('href')) return null
408
+ if (!state.shouldIntercept(anchor, event)) return null
409
+ return anchor
410
+ }
411
+
412
+ function onPointerOver(event: MouseEvent): void {
413
+ const state = active
414
+ if (!state) return
415
+ const anchor = prefetchableAnchor(event)
416
+ if (!anchor) return
417
+ // `mouseover` bubbles, so moving between descendants of the same link fires it
418
+ // repeatedly — don't restart the dwell each time we're already on this anchor.
419
+ if (anchor === state.hoverAnchor) return
420
+ clearHoverTimer(state)
421
+ state.hoverAnchor = anchor
422
+ const href = anchor.href
423
+ state.hoverTimer = setTimeout(() => {
424
+ state.hoverTimer = null
425
+ state.hoverAnchor = null
426
+ prefetch(href)
427
+ }, state.prefetchDelay)
428
+ }
429
+
430
+ function onPointerOut(event: MouseEvent): void {
431
+ const state = active
432
+ if (!state || !state.hoverAnchor) return
433
+ // `mouseout` also bubbles: moving between descendants inside the same `<a>`
434
+ // fires it even though the pointer is still over the link. Only cancel when
435
+ // the pointer actually left the dwelled anchor (it moved to a node outside it).
436
+ const to = event.relatedTarget as Node | null
437
+ if (to && state.hoverAnchor.contains(to)) return
438
+ clearHoverTimer(state)
439
+ }
440
+
441
+ function onFocusIn(event: FocusEvent): void {
442
+ const anchor = prefetchableAnchor(event)
443
+ if (anchor) prefetch(anchor.href) // keyboard focus is intentional — no dwell
444
+ }
445
+
446
+ function onPointerDown(event: MouseEvent): void {
447
+ if (event.button !== 0) return
448
+ const anchor = prefetchableAnchor(event)
449
+ if (anchor) prefetch(anchor.href)
450
+ }
package/src/seams.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Window bridges to the optional `@barefootjs/client` runtime.
3
+ *
4
+ * The router core never statically imports `@barefootjs/client` — that keeps it
5
+ * an *optional* peer, so a static-shell site ships the router with zero client
6
+ * runtime. Every coupling lives here, and every coupling is correct by default:
7
+ * `dispose`/`rehydrate` degrade through the SAME fallback chain ending at
8
+ * `@barefootjs/client/runtime`, and neither silently no-ops on a page that has
9
+ * islands (spec/router.md "Seams & correctness" — the #1910 `setupStreaming()`
10
+ * footgun, where islands leaked unless the dev opted in, is what this avoids).
11
+ */
12
+
13
+ import {
14
+ BF_HOST,
15
+ BF_SCOPE,
16
+ BF_SEAM_DISPOSE_WITHIN,
17
+ BF_SEAM_HYDRATE,
18
+ BF_SEAM_HYDRATE_WITHIN,
19
+ BF_SEAM_PUSH_SEARCH,
20
+ } from '@barefootjs/shared'
21
+
22
+ // Seam-name keys come from `@barefootjs/shared` so this reader and the client
23
+ // installer can never disagree on a property name (a typo would otherwise just
24
+ // fall through to the dynamic-import path and silently change behaviour).
25
+ interface ClientSeams {
26
+ /** Subtree-scoped re-hydration (O(region)); installed by the client runtime. */
27
+ [BF_SEAM_HYDRATE_WITHIN]?: (root: Element) => void
28
+ /** Subtree-scoped disposal; installed by the client runtime. */
29
+ [BF_SEAM_DISPOSE_WITHIN]?: (root: Element) => void
30
+ /** Whole-document re-hydration fallback. */
31
+ [BF_SEAM_HYDRATE]?: () => void
32
+ /**
33
+ * Push a new query string into the `searchParams()` env signal
34
+ * (`@barefootjs/client`, v0.5). The router owns popstate/query-only nav and
35
+ * pushes through this seam; the client installs it lazily on first
36
+ * `searchParams()` read, so the client package stays `sideEffects: false`.
37
+ */
38
+ [BF_SEAM_PUSH_SEARCH]?: (search: string) => void
39
+ }
40
+
41
+ /**
42
+ * Push the committed query string into the env signal, if a `searchParams()`
43
+ * consumer has registered the seam. Returns whether the seam exists — the
44
+ * caller uses that boolean to decide query-only-vs-structural navigation.
45
+ */
46
+ export function pushSearchSeam(search: string): boolean {
47
+ const w = window as unknown as ClientSeams
48
+ const push = w[BF_SEAM_PUSH_SEARCH]
49
+ if (typeof push !== 'function') return false
50
+ push(search)
51
+ return true
52
+ }
53
+
54
+ export async function defaultRehydrate(region: Element): Promise<void> {
55
+ const w = window as unknown as ClientSeams
56
+ // Prefer the subtree-scoped walk — O(region), not O(document).
57
+ const hydrateWithin = w[BF_SEAM_HYDRATE_WITHIN]
58
+ if (typeof hydrateWithin === 'function') {
59
+ hydrateWithin(region)
60
+ return
61
+ }
62
+ const hydrateAll = w[BF_SEAM_HYDRATE]
63
+ if (typeof hydrateAll === 'function') {
64
+ hydrateAll()
65
+ return
66
+ }
67
+ // Fall back to the runtime's named exports. The specifier is a *variable* so
68
+ // bundlers can't statically resolve (and thus bundle) the optional peer; the
69
+ // browser resolves the bare specifier through the page's import map.
70
+ try {
71
+ const spec = '@barefootjs/client/runtime'
72
+ const mod = (await import(spec)) as {
73
+ rehydrateScope?: (root: Element) => void
74
+ rehydrateAll?: () => void
75
+ }
76
+ if (mod.rehydrateScope) mod.rehydrateScope(region)
77
+ else mod.rehydrateAll?.()
78
+ } catch {
79
+ // A region with hydration markers but no reachable runtime is a real
80
+ // failure — the swapped content won't be interactive. Surface it rather
81
+ // than silently no-op (spec/router.md "Neither may silently no-op"). A
82
+ // static shell (no markers) genuinely has nothing to do, so stays quiet.
83
+ warnIfIslandsUnreachable(region, 're-hydrate')
84
+ }
85
+ }
86
+
87
+ export async function defaultDispose(region: Element): Promise<void> {
88
+ const w = window as unknown as ClientSeams
89
+ const disposeWithin = w[BF_SEAM_DISPOSE_WITHIN]
90
+ if (typeof disposeWithin === 'function') {
91
+ disposeWithin(region)
92
+ return
93
+ }
94
+ try {
95
+ const spec = '@barefootjs/client/runtime'
96
+ const mod = (await import(spec)) as { disposeScope?: (root: Element) => void }
97
+ mod.disposeScope?.(region)
98
+ } catch {
99
+ // Same as re-hydrate: a no-op dispose on a region that has islands leaks
100
+ // their handlers/effects across the navigation — surface it.
101
+ warnIfIslandsUnreachable(region, 'dispose')
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Does the region contain BarefootJS hydration markers (`bf-s` scope roots or
107
+ * `bf-h` child-scope hosts)? If so, the client runtime *should* be on the page,
108
+ * and failing to reach it is an error worth surfacing — not the silent
109
+ * "static shell" path.
110
+ */
111
+ export function regionHasIslands(region: Element): boolean {
112
+ return (
113
+ region.hasAttribute(BF_SCOPE) ||
114
+ region.hasAttribute(BF_HOST) ||
115
+ region.querySelector(`[${BF_SCOPE}],[${BF_HOST}]`) !== null
116
+ )
117
+ }
118
+
119
+ function warnIfIslandsUnreachable(region: Element, op: 'dispose' | 're-hydrate'): void {
120
+ if (!regionHasIslands(region)) return
121
+ console.error(
122
+ `[barefootjs/router] could not load @barefootjs/client/runtime to ${op} a region that contains islands. ` +
123
+ `The swapped content may leak handlers or not be interactive — ensure the runtime is served and mapped in the page's import map.`,
124
+ )
125
+ }
126
+
127
+ /** Full browser navigation — the "never worse than an MPA" floor. */
128
+ export function hardNavigate(url: string): void {
129
+ window.location.assign(url)
130
+ }