@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/region.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a fetched page and lift out the swappable region subtree(s).
|
|
3
|
+
*
|
|
4
|
+
* The router fetches an ordinary full-page HTML response (no protocol header),
|
|
5
|
+
* parses it client-side, and matches its `[bf-region]` boundaries against the
|
|
6
|
+
* live document. v0 swaps a single broad region; v2 matches compiler-derived
|
|
7
|
+
* nested/sibling regions by their stable `bf-region` id and swaps only the
|
|
8
|
+
* deepest ones whose *owned* content differs (spec/router.md "Regions"). Island
|
|
9
|
+
* module scripts (`<script type=module src>`) sit at body-end, outside any
|
|
10
|
+
* region, so they are collected from the whole parsed document.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { BF_HOST, BF_PROPS, BF_REGION, BF_SCOPE, BF_SCOPE_COMMENT_PREFIX } from '@barefootjs/shared'
|
|
14
|
+
import type { RouterState } from './types.ts'
|
|
15
|
+
|
|
16
|
+
/** Parse a fetched page's HTML into a detached document. */
|
|
17
|
+
export function parseDocument(html: string): Document {
|
|
18
|
+
return new DOMParser().parseFromString(html, 'text/html')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Same-origin absolute URLs of `<script type=module src>` in a tree, resolved
|
|
24
|
+
* against `baseUrl`. For a fetched page that is the response's **final URL**
|
|
25
|
+
* (not `location`), so a relative module `src` in the incoming document loads
|
|
26
|
+
* from the right place (spec/router.md lifecycle step 4).
|
|
27
|
+
*/
|
|
28
|
+
export function collectModuleScripts(root: ParentNode, baseUrl: string): Set<string> {
|
|
29
|
+
const out = new Set<string>()
|
|
30
|
+
for (const s of root.querySelectorAll('script[type="module"][src]')) {
|
|
31
|
+
const src = s.getAttribute('src')
|
|
32
|
+
if (!src) continue
|
|
33
|
+
try {
|
|
34
|
+
const url = new URL(src, baseUrl)
|
|
35
|
+
// Cross-origin module scripts are the browser's to own — skip them.
|
|
36
|
+
if (url.origin === window.location.origin) out.add(url.href)
|
|
37
|
+
} catch {
|
|
38
|
+
/* skip un-resolvable src */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A swap target: a live region element and its counterpart in the incoming doc. */
|
|
45
|
+
export interface RegionSwap {
|
|
46
|
+
current: Element
|
|
47
|
+
incoming: Element
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type SwapPlan =
|
|
51
|
+
/**
|
|
52
|
+
* Matched compiler-derived regions: swap exactly `targets` (may be empty when
|
|
53
|
+
* nothing changed). `incomingKeys` is the owned-content key per matched region
|
|
54
|
+
* id from the **incoming server render** — the caller commits it as the new
|
|
55
|
+
* per-region baseline (see {@link planRegionSwaps}).
|
|
56
|
+
*/
|
|
57
|
+
| { mode: 'regions'; targets: RegionSwap[]; incomingKeys: Map<string, string> }
|
|
58
|
+
/** Region ids don't line up (or collide): fall back to the single broadest-region swap (v0). */
|
|
59
|
+
| { mode: 'broadest' }
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Index every `[bf-region]` in `root` by its `bf-region` id. Returns `null` if
|
|
63
|
+
* two regions share an id — they can't be matched 1:1 across documents, so the
|
|
64
|
+
* caller falls back to the broadest single-region swap.
|
|
65
|
+
*/
|
|
66
|
+
function indexRegions(root: ParentNode, selector: string): Map<string, Element> | null {
|
|
67
|
+
const map = new Map<string, Element>()
|
|
68
|
+
for (const el of root.querySelectorAll(selector)) {
|
|
69
|
+
const id = el.getAttribute(BF_REGION) ?? ''
|
|
70
|
+
if (map.has(id)) return null
|
|
71
|
+
map.set(id, el)
|
|
72
|
+
}
|
|
73
|
+
return map
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A region's **owned** content: its inner HTML with every *nested* `[bf-region]`
|
|
78
|
+
* subtree masked out (replaced by an id-keyed placeholder), and with per-render
|
|
79
|
+
* **volatile hydration scaffolding** normalized away. Two regions compare equal
|
|
80
|
+
* when only their nested regions' interiors differ — so an outer region stays
|
|
81
|
+
* mounted when just an inner region changed (the deepest differing region is the
|
|
82
|
+
* one that swaps).
|
|
83
|
+
*
|
|
84
|
+
* The normalization matters: a top-level island's scope id is randomized per
|
|
85
|
+
* server render (`<div bf-s="Counter_a1b2c3">`), so two renders of the *same*
|
|
86
|
+
* region are never byte-identical. Comparing raw `innerHTML` would flag every
|
|
87
|
+
* region containing an island as "changed" and swap away its state — the
|
|
88
|
+
* opposite of v2's goal. The diff therefore compares *content*, ignoring the
|
|
89
|
+
* scope-id-carrying markers (`bf-s`, `bf-h`, and the id inside `bf-scope:`
|
|
90
|
+
* comments); the structural markers (`bf` slot refs, `bf-m` slot ids, `bf-r`)
|
|
91
|
+
* and any props stay, so a real content/prop change is still detected.
|
|
92
|
+
*/
|
|
93
|
+
export function ownedContentKey(region: Element, selector: string): string {
|
|
94
|
+
const clone = region.cloneNode(true) as Element
|
|
95
|
+
// `querySelectorAll` on the clone returns descendants only (not the clone
|
|
96
|
+
// itself), so this masks nested regions, not this one.
|
|
97
|
+
for (const nested of Array.from(clone.querySelectorAll(selector))) {
|
|
98
|
+
// A deeper region already vanished when its ancestor region was masked.
|
|
99
|
+
if (!clone.contains(nested)) continue
|
|
100
|
+
const mask = (clone.ownerDocument ?? document).createElement('bf-region-mask')
|
|
101
|
+
mask.setAttribute('data-id', nested.getAttribute(BF_REGION) ?? '')
|
|
102
|
+
nested.replaceWith(mask)
|
|
103
|
+
}
|
|
104
|
+
stripVolatileHydration(clone)
|
|
105
|
+
return clone.innerHTML
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Hydration attributes carrying a per-render-random scope id — ignored by the diff. */
|
|
109
|
+
const VOLATILE_ATTRS = [BF_SCOPE, BF_HOST]
|
|
110
|
+
|
|
111
|
+
/** Strip per-render-volatile hydration scaffolding so the diff compares content, not scope ids. */
|
|
112
|
+
function stripVolatileHydration(root: Element): void {
|
|
113
|
+
for (const a of VOLATILE_ATTRS) root.removeAttribute(a)
|
|
114
|
+
normalizePropsAttr(root)
|
|
115
|
+
const walker = (root.ownerDocument ?? document).createTreeWalker(
|
|
116
|
+
root,
|
|
117
|
+
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT,
|
|
118
|
+
)
|
|
119
|
+
while (walker.nextNode()) {
|
|
120
|
+
const node = walker.currentNode
|
|
121
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
122
|
+
for (const a of VOLATILE_ATTRS) (node as Element).removeAttribute(a)
|
|
123
|
+
normalizePropsAttr(node as Element)
|
|
124
|
+
} else if ((node as Comment).data.startsWith(BF_SCOPE_COMMENT_PREFIX)) {
|
|
125
|
+
;(node as Comment).data = normalizeScopeComment((node as Comment).data)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Blank the random `scopeID` inside a `bf-p` props attribute — the Go template
|
|
132
|
+
* adapter's hydration form, emitted on every root island (`<div bf-p='{"scopeID":
|
|
133
|
+
* "Sidebar_a1b2c3","pins":0}'>`). The scope id is regenerated per server render,
|
|
134
|
+
* so without this a persistent sibling region whose island sits *inside* the
|
|
135
|
+
* region element (e.g. the hand-authored `<aside bf-region>` sidebar) would
|
|
136
|
+
* compare unequal every navigation and get swapped away, resetting its state.
|
|
137
|
+
*
|
|
138
|
+
* Mirrors {@link normalizeScopeComment} (the JS adapters carry props in a
|
|
139
|
+
* `bf-scope:` comment instead): the scope id is blanked, every other prop kept,
|
|
140
|
+
* so a real prop change is still detected. Non-JSON / scope-id-free values are
|
|
141
|
+
* left untouched.
|
|
142
|
+
*
|
|
143
|
+
* Known limitation: only the TOP-LEVEL `scopeID` is blanked. A serialized
|
|
144
|
+
* `children` prop embeds nested islands' scope ids, which are NOT normalized —
|
|
145
|
+
* harmless today (the only such root, `PageShell`, IS a region element, so its
|
|
146
|
+
* `bf-p` is excluded from the innerHTML diff), but a `children`-carrying root
|
|
147
|
+
* placed *inside* a persistent region would still false-swap. See
|
|
148
|
+
* https://github.com/piconic-ai/barefootjs/issues/1952.
|
|
149
|
+
*/
|
|
150
|
+
function normalizePropsAttr(el: Element): void {
|
|
151
|
+
const raw = el.getAttribute(BF_PROPS)
|
|
152
|
+
if (raw === null) return
|
|
153
|
+
try {
|
|
154
|
+
const obj = JSON.parse(raw)
|
|
155
|
+
if (obj && typeof obj === 'object' && 'scopeID' in obj) {
|
|
156
|
+
obj.scopeID = ''
|
|
157
|
+
el.setAttribute(BF_PROPS, JSON.stringify(obj))
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
// Not JSON we recognise — leave the attribute as authored.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Normalize a `bf-scope:<scopeId>[|h=<host>][|m=<slot>][|<props>]` comment: blank
|
|
166
|
+
* the random `<scopeId>` and drop any `h=<host>` host token (both per-render
|
|
167
|
+
* volatile), keeping the structural slot (`m=`) and props so a real prop change
|
|
168
|
+
* is still detected.
|
|
169
|
+
*/
|
|
170
|
+
function normalizeScopeComment(data: string): string {
|
|
171
|
+
const parts = data.slice(BF_SCOPE_COMMENT_PREFIX.length).split('|')
|
|
172
|
+
parts[0] = '' // scope id → blanked
|
|
173
|
+
const kept = parts.filter((p, i) => i === 0 || !p.startsWith('h='))
|
|
174
|
+
return BF_SCOPE_COMMENT_PREFIX + kept.join('|')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Decide which regions to swap between the live document and a parsed incoming
|
|
179
|
+
* document. When both expose the **same set** of region ids, swap the *topmost*
|
|
180
|
+
* regions whose owned content differs (an ancestor swap rebuilds its nested
|
|
181
|
+
* regions, so a nested candidate inside another candidate is dropped). When the
|
|
182
|
+
* id sets differ or collide, fall back to the broadest single-region swap.
|
|
183
|
+
*
|
|
184
|
+
* The "differs" test compares the incoming region's **server-rendered** owned
|
|
185
|
+
* content against `baselines` — the owned-content key captured from the server
|
|
186
|
+
* render currently displayed in that region — **not** the live DOM. A live
|
|
187
|
+
* region's DOM may have been mutated by its islands (signal-driven updates), so
|
|
188
|
+
* comparing against it would flag an unchanged region as changed and swap away
|
|
189
|
+
* its state — the opposite of v2's goal. The caller seeds `baselines` from the
|
|
190
|
+
* initial document and refreshes it from `incomingKeys` after each navigation.
|
|
191
|
+
* A region missing from `baselines` falls back to its live owned content.
|
|
192
|
+
*/
|
|
193
|
+
export function planRegionSwaps(
|
|
194
|
+
currentRoot: ParentNode,
|
|
195
|
+
incomingRoot: ParentNode,
|
|
196
|
+
selector: string,
|
|
197
|
+
baselines: ReadonlyMap<string, string>,
|
|
198
|
+
): SwapPlan {
|
|
199
|
+
const cur = indexRegions(currentRoot, selector)
|
|
200
|
+
const inc = indexRegions(incomingRoot, selector)
|
|
201
|
+
if (!cur || !inc || !sameKeys(cur, inc)) return { mode: 'broadest' }
|
|
202
|
+
|
|
203
|
+
const incomingKeys = new Map<string, string>()
|
|
204
|
+
const candidates: RegionSwap[] = []
|
|
205
|
+
for (const [id, current] of cur) {
|
|
206
|
+
const incoming = inc.get(id) as Element
|
|
207
|
+
const incomingKey = ownedContentKey(incoming, selector)
|
|
208
|
+
incomingKeys.set(id, incomingKey)
|
|
209
|
+
const baseline = baselines.get(id) ?? ownedContentKey(current, selector)
|
|
210
|
+
if (incomingKey !== baseline) candidates.push({ current, incoming })
|
|
211
|
+
}
|
|
212
|
+
// Drop any candidate nested inside another candidate (in the live document):
|
|
213
|
+
// swapping the ancestor replaces it anyway.
|
|
214
|
+
const targets = candidates.filter(
|
|
215
|
+
(c) => !candidates.some((o) => o !== c && o.current.contains(c.current)),
|
|
216
|
+
)
|
|
217
|
+
return { mode: 'regions', targets, incomingKeys }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Capture the owned-content key of every `[bf-region]` in `root`, keyed by id —
|
|
222
|
+
* the per-region server-render baseline the swap planner compares against.
|
|
223
|
+
*/
|
|
224
|
+
export function captureRegionBaselines(root: ParentNode, selector: string): Map<string, string> {
|
|
225
|
+
const out = new Map<string, string>()
|
|
226
|
+
for (const el of root.querySelectorAll(selector)) {
|
|
227
|
+
out.set(el.getAttribute(BF_REGION) ?? '', ownedContentKey(el, selector))
|
|
228
|
+
}
|
|
229
|
+
return out
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function sameKeys(a: Map<string, unknown>, b: Map<string, unknown>): boolean {
|
|
233
|
+
if (a.size !== b.size) return false
|
|
234
|
+
for (const k of a.keys()) if (!b.has(k)) return false
|
|
235
|
+
return true
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* True when `region` contains every other `[bf-region]` in its document — i.e.
|
|
240
|
+
* it is a single root whose swap rebuilds them all. Used by the broadest
|
|
241
|
+
* fallback: a root may be swapped wholesale (the v0 behaviour), but if the
|
|
242
|
+
* regions are siblings a single swap would only half-update the page, so the
|
|
243
|
+
* caller hard-navigates instead.
|
|
244
|
+
*/
|
|
245
|
+
export function isRootRegion(region: Element, selector: string): boolean {
|
|
246
|
+
const root = region.ownerDocument ?? document
|
|
247
|
+
for (const el of root.querySelectorAll(selector)) {
|
|
248
|
+
if (el !== region && !region.contains(el)) return false
|
|
249
|
+
}
|
|
250
|
+
return true
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Adopt an incoming region's child nodes into the live document so they're ready
|
|
255
|
+
* to insert (the parsed nodes belong to a detached document).
|
|
256
|
+
*/
|
|
257
|
+
export function importRegionChildren(incoming: Element): Node[] {
|
|
258
|
+
return Array.from(incoming.childNodes).map((n) => document.importNode(n, true))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Prefetch-path read: parse once, confirm the page belongs to this shell, and
|
|
263
|
+
* return only its island module srcs (resolved against `baseUrl`). It does
|
|
264
|
+
* **not** clone/import any region subtree — prefetch only needs the module list,
|
|
265
|
+
* and importing a large region is wasted work. Returns `null` when the page has
|
|
266
|
+
* no region.
|
|
267
|
+
*/
|
|
268
|
+
export function collectRegionModuleSrcs(
|
|
269
|
+
html: string,
|
|
270
|
+
selector: string,
|
|
271
|
+
baseUrl: string,
|
|
272
|
+
): string[] | null {
|
|
273
|
+
const doc = new DOMParser().parseFromString(html, 'text/html')
|
|
274
|
+
if (!doc.querySelector(selector)) return null
|
|
275
|
+
return [...collectModuleScripts(doc, baseUrl)]
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function loadNewModules(state: RouterState, srcs: string[]): Promise<void> {
|
|
279
|
+
const fresh = srcs.filter((s) => !state.loadedModules.has(s))
|
|
280
|
+
await Promise.all(
|
|
281
|
+
fresh.map(async (src) => {
|
|
282
|
+
try {
|
|
283
|
+
await state.loadModule(src)
|
|
284
|
+
// Mark as loaded only AFTER a successful import. Marking *before* the
|
|
285
|
+
// await (to dedupe a concurrent nav) risks a false positive: if this
|
|
286
|
+
// import fails while a second navigation is already in flight, that nav
|
|
287
|
+
// would see the src as "loaded", skip it, and hydrate without the module.
|
|
288
|
+
// `loadModule` (native `import()`) is idempotent, so two overlapping
|
|
289
|
+
// navigations importing the same src is a cheap no-op second call, not a
|
|
290
|
+
// double fetch.
|
|
291
|
+
state.loadedModules.add(src)
|
|
292
|
+
} catch {
|
|
293
|
+
// Left unmarked → a later navigation retries the import.
|
|
294
|
+
}
|
|
295
|
+
}),
|
|
296
|
+
)
|
|
297
|
+
}
|