@barefootjs/router 0.28.0 → 0.29.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 CHANGED
@@ -57,12 +57,75 @@ setup step.
57
57
  existing state (scroll-restoration libs, framework state).
58
58
  - **A11y**: focus moves into the swapped region (its first heading) and the new
59
59
  title is announced via a polite live region.
60
+ - **Head metadata**: title, description, `og:`/`twitter:`, canonical and friends
61
+ are reconciled against the incoming page; head *resources* are not (see below).
60
62
  - **Persistence** (`data-bf-permanent`): an element marked
61
63
  `<div data-bf-permanent="player">` keeps its *live* node across a swap — its
62
64
  state, media playback, scroll, and hydrated scope survive — matched between
63
65
  documents by the attribute value (or `id`). A no-op when no element is marked;
64
66
  pass `morph: false` for a plain swap.
65
67
 
68
+ ## `<head>`: metadata is reconciled, resources are not
69
+
70
+ A region is a **body** subtree, so `<head>` is not swapped wholesale. It splits
71
+ in two, and the split is the whole contract.
72
+
73
+ ### Page metadata — reconciled on every swap, always
74
+
75
+ Page metadata is page-scoped by definition, so the router brings a closed
76
+ allowlist of it in line with the incoming document. This always runs and there
77
+ is no flag to disable it: a stale `<meta name="description">` is wrongness you
78
+ *cannot see* in development (unlike the tab title), and this package doesn't
79
+ leave that class opt-in.
80
+
81
+ | head node | key |
82
+ | --- | --- |
83
+ | `<title>` | — |
84
+ | `<meta name="description \| keywords \| robots \| author \| theme-color">` | `name` |
85
+ | `<meta property="og:*">` / `<meta name="twitter:*">` | `name` or `property` |
86
+ | `<link rel="canonical \| alternate \| prev \| next">` | `rel` + `hreflang`/`type`/`media` |
87
+
88
+ A key in both documents is replaced (skipped when the nodes are already equal,
89
+ so metadata shared across routes causes no DOM churn); a key only in the
90
+ incoming page is added; a key only in the live page is **removed**, so it can't
91
+ leak forward into every later route.
92
+
93
+ Anything whose key isn't in that table is never read, replaced, or removed —
94
+ runtime-injected analytics tags, CSP `<meta http-equiv>`, `<link rel=preconnect>`
95
+ and friends are safe by construction. (This is the deliberate difference from
96
+ Turbo, which removes every untracked head element.) Opt a node out with
97
+ `data-bf-head="false"` when the page itself owns it.
98
+
99
+ ### Head resources — untouched
100
+
101
+ `<link rel="stylesheet">`, `<script>`, and `<style>` in `<head>` are left alone
102
+ in both directions. Not an oversight: a resource's lifetime isn't derivable from
103
+ the incoming document. The shell, a `[data-bf-permanent]` node, a portal, or an
104
+ island that outlives the region may still depend on a sheet the next page's head
105
+ doesn't list, so "absent downstream" is no evidence of "no longer needed".
106
+
107
+ That makes a **route-scoped stylesheet in `<head>`** the one real trap:
108
+ navigating *into* the route renders it unstyled (a reload "fixes" it, which
109
+ points the investigation at caching or the build instead of at navigation), and
110
+ navigating *out* leaves the sheet linked, so its rules then apply to every route
111
+ after it.
112
+
113
+ Put it **inside** the region, where it enters and leaves with the swap:
114
+
115
+ ```tsx
116
+ <Region>
117
+ {isEditor ? <link rel="stylesheet" href="/editor.css" /> : null}
118
+ {children}
119
+ </Region>
120
+ ```
121
+
122
+ `rel="stylesheet"` is body-ok per HTML, so this is valid — and it is the right
123
+ placement under a region-swap contract, not a workaround. It gets both orderings
124
+ right *by construction* (the sheet is inserted with the content it styles and
125
+ removed with it), with no load awaited in the navigation path. Sheets that are
126
+ genuinely global stay in `<head>`, where never touching them is exactly what you
127
+ want.
128
+
66
129
  ## Scope
67
130
 
68
131
  A **single authored region** (the broadest `[bf-region]` match), correct by
package/dist/head.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `<head>` metadata reconciliation across a soft navigation (#2438).
3
+ *
4
+ * A region is a **body** subtree, so a swap leaves `<head>` alone — and for
5
+ * *resources* (`<link rel="stylesheet">`, `<script>`, `<style>`) that is the
6
+ * contract, not a gap: a resource's lifetime depends on what still needs it
7
+ * (the shell, a `[data-bf-permanent]` node, a portal, an island that outlives
8
+ * the region), which the incoming document is no evidence for. Turbo reaches
9
+ * the same conclusion from the other side — it never removes a stylesheet
10
+ * unless the author writes `data-turbo-track="dynamic"`. Route-scoped sheets
11
+ * belong *inside* the region, where both orderings are right by construction.
12
+ *
13
+ * **Page metadata is the opposite case.** It is page-scoped by definition,
14
+ * costs no load, has no layout effect or ordering hazard, and is idempotent —
15
+ * and a stale `<meta name="description">` is *invisible* wrongness: unlike the
16
+ * tab title you cannot see it in development. That is the class this package
17
+ * refuses to leave opt-in (spec/router.md, "correct by default"), so it is
18
+ * reconciled on every swap, always — there is no flag to disable it.
19
+ *
20
+ * The reconciled set is a **closed allowlist**, which is the deliberate
21
+ * difference from Turbo's `provisionalElements` (everything untracked, so
22
+ * runtime-injected analytics/CSP nodes get caught in the sweep). Here anything
23
+ * whose key isn't listed below is never read, never replaced, and never
24
+ * removed.
25
+ */
26
+ /**
27
+ * Bring the live `<head>`'s allowlisted metadata in line with `incomingDoc`.
28
+ *
29
+ * - key in both → replace in place (skipped when the nodes are already equal,
30
+ * so metadata shared across routes causes no DOM churn)
31
+ * - key only incoming → appended
32
+ * - key only current → removed, so it can't leak forward into every later route
33
+ * the way an unmanaged `<link rel="stylesheet">` does
34
+ *
35
+ * Duplicates under one key are collapsed to the incoming node — a page with two
36
+ * `<meta name="description">` is malformed, and leaving the extra behind would
37
+ * defeat the reconciliation.
38
+ *
39
+ * `<title>` is **not** handled here: the router writes it alongside this call
40
+ * because the route announcement (`announceNavigation`) needs the same string.
41
+ *
42
+ * Ordering-free — no load, no layout effect — so the caller may run it at any
43
+ * point around the swap.
44
+ */
45
+ export declare function reconcileHead(incomingDoc: Document): void;
46
+ //# sourceMappingURL=head.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"head.d.ts","sourceRoot":"","sources":["../src/head.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AA4EH;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,aAAa,CAAC,WAAW,EAAE,QAAQ,GAAG,IAAI,CAyBzD"}
package/dist/index.js CHANGED
@@ -60,6 +60,83 @@ function loadPage(state, url) {
60
60
  return snap;
61
61
  }
62
62
 
63
+ // src/head.ts
64
+ var META_NAMES = new Set(["description", "keywords", "robots", "author", "theme-color"]);
65
+ var META_PREFIXES = ["og:", "twitter:"];
66
+ var LINK_RELS = new Set(["canonical", "alternate", "prev", "next"]);
67
+ function norm(value) {
68
+ return (value ?? "").trim().toLowerCase();
69
+ }
70
+ function optedOut(el) {
71
+ return el.getAttribute("data-bf-head") === "false";
72
+ }
73
+ function headKey(el) {
74
+ if (optedOut(el))
75
+ return null;
76
+ const tag = el.tagName.toLowerCase();
77
+ if (tag === "meta") {
78
+ const id = norm(el.getAttribute("name") ?? el.getAttribute("property"));
79
+ if (!id)
80
+ return null;
81
+ if (!META_NAMES.has(id) && !META_PREFIXES.some((p) => id.startsWith(p)))
82
+ return null;
83
+ return `meta:${id}`;
84
+ }
85
+ if (tag === "link") {
86
+ const rel = norm(el.getAttribute("rel"));
87
+ if (!LINK_RELS.has(rel))
88
+ return null;
89
+ const hreflang = norm(el.getAttribute("hreflang"));
90
+ const type = norm(el.getAttribute("type"));
91
+ const media = norm(el.getAttribute("media"));
92
+ return `link:${rel}|${hreflang}|${type}|${media}`;
93
+ }
94
+ return null;
95
+ }
96
+ function indexHead(head) {
97
+ const byKey = new Map;
98
+ for (const el of head.querySelectorAll("meta, link")) {
99
+ const key = headKey(el);
100
+ if (!key)
101
+ continue;
102
+ const bucket = byKey.get(key);
103
+ if (bucket)
104
+ bucket.push(el);
105
+ else
106
+ byKey.set(key, [el]);
107
+ }
108
+ return byKey;
109
+ }
110
+ function reconcileHead(incomingDoc) {
111
+ const head = document.head;
112
+ const incomingHead = incomingDoc.head;
113
+ if (!head || !incomingHead)
114
+ return;
115
+ const current = indexHead(head);
116
+ const incoming = indexHead(incomingHead);
117
+ if (current.size === 0 && incoming.size === 0)
118
+ return;
119
+ for (const [key, nodes] of incoming) {
120
+ const live = current.get(key);
121
+ const replacement = document.importNode(nodes[0], true);
122
+ if (!live) {
123
+ head.append(replacement);
124
+ continue;
125
+ }
126
+ const [first, ...duplicates] = live;
127
+ if (!first.isEqualNode(replacement))
128
+ first.replaceWith(replacement);
129
+ for (const dup of duplicates)
130
+ dup.remove();
131
+ }
132
+ for (const [key, nodes] of current) {
133
+ if (incoming.has(key))
134
+ continue;
135
+ for (const node of nodes)
136
+ node.remove();
137
+ }
138
+ }
139
+
63
140
  // src/region.ts
64
141
  import { BF_HOST, BF_PROPS, BF_REGION, BF_SCOPE, BF_SCOPE_COMMENT_PREFIX, BF_SCOPE_COMMENT_END_PREFIX } from "@barefootjs/shared";
65
142
  function parseDocument(html) {
@@ -524,6 +601,7 @@ async function navigate(url, options = {}) {
524
601
  }
525
602
  if (title !== null)
526
603
  document.title = title;
604
+ reconcileHead(incomingDoc);
527
605
  if (plan.mode === "regions") {
528
606
  for (const [id, key] of plan.incomingKeys)
529
607
  state.regionBaselines.set(id, key);
@@ -1 +1 @@
1
- {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAuBH,OAAO,KAAK,EACV,eAAe,EAEf,MAAM,EACN,aAAa,EAEd,MAAM,YAAY,CAAA;AAInB,wBAAgB,WAAW,CAAC,OAAO,GAAE,aAAkB,GAAG,MAAM,CAkE/D;AA4FD,wBAAsB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,eAAoB,GAAG,OAAO,CAAC,IAAI,CAAC,CA6JxF"}
1
+ {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAwBH,OAAO,KAAK,EACV,eAAe,EAEf,MAAM,EACN,aAAa,EAEd,MAAM,YAAY,CAAA;AAInB,wBAAgB,WAAW,CAAC,OAAO,GAAE,aAAkB,GAAG,MAAM,CAkE/D;AA4FD,wBAAsB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,eAAoB,GAAG,OAAO,CAAC,IAAI,CAAC,CAsKxF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/router",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Backend-agnostic partial-navigation client router for BarefootJS — swaps only the page region and re-hydrates the islands inside it",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -40,7 +40,7 @@
40
40
  "directory": "packages/router"
41
41
  },
42
42
  "dependencies": {
43
- "@barefootjs/shared": "0.28.0"
43
+ "@barefootjs/shared": "0.29.0"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@barefootjs/client": ">=0.14.0"
@@ -51,7 +51,7 @@
51
51
  }
52
52
  },
53
53
  "devDependencies": {
54
- "@barefootjs/client": "^0.28.0",
54
+ "@barefootjs/client": "^0.29.0",
55
55
  "@happy-dom/global-registrator": "^20.0.11",
56
56
  "typescript": "^5.0.0"
57
57
  }
package/src/head.ts ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `<head>` metadata reconciliation across a soft navigation (#2438).
3
+ *
4
+ * A region is a **body** subtree, so a swap leaves `<head>` alone — and for
5
+ * *resources* (`<link rel="stylesheet">`, `<script>`, `<style>`) that is the
6
+ * contract, not a gap: a resource's lifetime depends on what still needs it
7
+ * (the shell, a `[data-bf-permanent]` node, a portal, an island that outlives
8
+ * the region), which the incoming document is no evidence for. Turbo reaches
9
+ * the same conclusion from the other side — it never removes a stylesheet
10
+ * unless the author writes `data-turbo-track="dynamic"`. Route-scoped sheets
11
+ * belong *inside* the region, where both orderings are right by construction.
12
+ *
13
+ * **Page metadata is the opposite case.** It is page-scoped by definition,
14
+ * costs no load, has no layout effect or ordering hazard, and is idempotent —
15
+ * and a stale `<meta name="description">` is *invisible* wrongness: unlike the
16
+ * tab title you cannot see it in development. That is the class this package
17
+ * refuses to leave opt-in (spec/router.md, "correct by default"), so it is
18
+ * reconciled on every swap, always — there is no flag to disable it.
19
+ *
20
+ * The reconciled set is a **closed allowlist**, which is the deliberate
21
+ * difference from Turbo's `provisionalElements` (everything untracked, so
22
+ * runtime-injected analytics/CSP nodes get caught in the sweep). Here anything
23
+ * whose key isn't listed below is never read, never replaced, and never
24
+ * removed.
25
+ */
26
+
27
+ /** `<meta name>` / `<meta property>` values reconciled by exact match. */
28
+ const META_NAMES = new Set(['description', 'keywords', 'robots', 'author', 'theme-color'])
29
+
30
+ /** …and by prefix, for the two social-card namespaces. */
31
+ const META_PREFIXES = ['og:', 'twitter:']
32
+
33
+ /**
34
+ * `<link rel>` values reconciled. Matched against the **whole** `rel`, not its
35
+ * tokens, so a multi-token `rel="alternate stylesheet"` — a resource, with a
36
+ * resource's unknowable lifetime — falls outside the allowlist by construction.
37
+ */
38
+ const LINK_RELS = new Set(['canonical', 'alternate', 'prev', 'next'])
39
+
40
+ /**
41
+ * Fold an attribute to its comparable form. Every attribute in a key is
42
+ * case-insensitive, and incidental whitespace (`hreflang=" en-US "`) must not
43
+ * split one logical slot into two — a split key would append a duplicate on
44
+ * every navigation instead of replacing the node.
45
+ */
46
+ function norm(value: string | null): string {
47
+ return (value ?? '').trim().toLowerCase()
48
+ }
49
+
50
+ /** Opt-out for a node the page itself owns: `<meta name="robots" data-bf-head="false">`. */
51
+ function optedOut(el: Element): boolean {
52
+ return el.getAttribute('data-bf-head') === 'false'
53
+ }
54
+
55
+ /**
56
+ * The identity a node is matched by across the two documents, or `null` when it
57
+ * is outside the allowlist (i.e. not ours to touch).
58
+ *
59
+ * `name` and `property` collapse into one key space on purpose: a page that
60
+ * writes `<meta property="og:title">` where the previous one wrote
61
+ * `<meta name="og:title">` still means the same slot.
62
+ */
63
+ function headKey(el: Element): string | null {
64
+ if (optedOut(el)) return null
65
+ const tag = el.tagName.toLowerCase()
66
+ if (tag === 'meta') {
67
+ const id = norm(el.getAttribute('name') ?? el.getAttribute('property'))
68
+ if (!id) return null
69
+ if (!META_NAMES.has(id) && !META_PREFIXES.some((p) => id.startsWith(p))) return null
70
+ return `meta:${id}`
71
+ }
72
+ if (tag === 'link') {
73
+ const rel = norm(el.getAttribute('rel'))
74
+ if (!LINK_RELS.has(rel)) return null
75
+ // `alternate` is repeatable — a locale, a feed, a print sheet are distinct
76
+ // slots, so the discriminating attributes are part of the key. All three
77
+ // are case-insensitive (BCP 47 tags, MIME types, media queries), so they
78
+ // are normalized: `hreflang="en-US"` and `en-us` are one slot, not two
79
+ // that would accumulate a duplicate on every navigation.
80
+ const hreflang = norm(el.getAttribute('hreflang'))
81
+ const type = norm(el.getAttribute('type'))
82
+ const media = norm(el.getAttribute('media'))
83
+ return `link:${rel}|${hreflang}|${type}|${media}`
84
+ }
85
+ return null
86
+ }
87
+
88
+ /** Index a head's allowlisted nodes by key, keeping duplicates in document order. */
89
+ function indexHead(head: Element): Map<string, Element[]> {
90
+ const byKey = new Map<string, Element[]>()
91
+ for (const el of head.querySelectorAll('meta, link')) {
92
+ const key = headKey(el)
93
+ if (!key) continue
94
+ const bucket = byKey.get(key)
95
+ if (bucket) bucket.push(el)
96
+ else byKey.set(key, [el])
97
+ }
98
+ return byKey
99
+ }
100
+
101
+ /**
102
+ * Bring the live `<head>`'s allowlisted metadata in line with `incomingDoc`.
103
+ *
104
+ * - key in both → replace in place (skipped when the nodes are already equal,
105
+ * so metadata shared across routes causes no DOM churn)
106
+ * - key only incoming → appended
107
+ * - key only current → removed, so it can't leak forward into every later route
108
+ * the way an unmanaged `<link rel="stylesheet">` does
109
+ *
110
+ * Duplicates under one key are collapsed to the incoming node — a page with two
111
+ * `<meta name="description">` is malformed, and leaving the extra behind would
112
+ * defeat the reconciliation.
113
+ *
114
+ * `<title>` is **not** handled here: the router writes it alongside this call
115
+ * because the route announcement (`announceNavigation`) needs the same string.
116
+ *
117
+ * Ordering-free — no load, no layout effect — so the caller may run it at any
118
+ * point around the swap.
119
+ */
120
+ export function reconcileHead(incomingDoc: Document): void {
121
+ const head = document.head
122
+ const incomingHead = incomingDoc.head
123
+ if (!head || !incomingHead) return
124
+
125
+ const current = indexHead(head)
126
+ const incoming = indexHead(incomingHead)
127
+ if (current.size === 0 && incoming.size === 0) return
128
+
129
+ for (const [key, nodes] of incoming) {
130
+ const live = current.get(key)
131
+ const replacement = document.importNode(nodes[0], true)
132
+ if (!live) {
133
+ head.append(replacement)
134
+ continue
135
+ }
136
+ const [first, ...duplicates] = live
137
+ if (!first.isEqualNode(replacement)) first.replaceWith(replacement)
138
+ for (const dup of duplicates) dup.remove()
139
+ }
140
+
141
+ for (const [key, nodes] of current) {
142
+ if (incoming.has(key)) continue
143
+ for (const node of nodes) node.remove()
144
+ }
145
+ }
package/src/router.ts CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { BF_REGION } from '@barefootjs/shared'
12
12
  import { loadPage } from './cache.ts'
13
+ import { reconcileHead } from './head.ts'
13
14
  import {
14
15
  captureRegionBaselines,
15
16
  collectModuleScripts,
@@ -297,7 +298,16 @@ export async function navigate(url: string, options: NavigateOptions = {}): Prom
297
298
  current.replaceChildren(fragment)
298
299
  swapped.push({ region: current, outgoing })
299
300
  }
301
+ // Commit the page's identity: the title (also handed to the route
302
+ // announcement below) and the allowlisted `<head>` metadata — description,
303
+ // canonical, og:/twitter: (`reconcileHead`, #2438). Head *resources*
304
+ // (`<link rel="stylesheet">`, scripts) stay unmanaged by contract, not by
305
+ // oversight: their lifetime isn't derivable from the incoming document, so
306
+ // a route-scoped stylesheet belongs inside the region, where it enters and
307
+ // leaves with the swap. Both are ordering-free, so they sit with the
308
+ // synchronous swaps rather than in the awaited tail.
300
309
  if (title !== null) document.title = title
310
+ reconcileHead(incomingDoc)
301
311
 
302
312
  // Refresh the per-region baselines to the server render now displayed: from
303
313
  // the incoming keys (matched regions), else recaptured from the live DOM