@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/README.md +73 -0
- package/dist/a11y.d.ts +24 -0
- package/dist/a11y.d.ts.map +1 -0
- package/dist/cache.d.ts +15 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +655 -0
- package/dist/morph.d.ts +30 -0
- package/dist/morph.d.ts.map +1 -0
- package/dist/region.d.ts +105 -0
- package/dist/region.d.ts.map +1 -0
- package/dist/router.d.ts +13 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/seams.d.ts +29 -0
- package/dist/seams.d.ts.map +1 -0
- package/dist/types.d.ts +135 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +65 -0
- package/src/a11y.ts +66 -0
- package/src/cache.ts +77 -0
- package/src/index.ts +5 -0
- package/src/morph.ts +69 -0
- package/src/region.ts +297 -0
- package/src/router.ts +450 -0
- package/src/seams.ts +130 -0
- package/src/types.ts +140 -0
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
|
+
}
|