@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/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
+ }