@barefootjs/router 0.28.1 → 0.30.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,11 +57,81 @@ 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.
67
+ - **Swap in flight** (`data-bf-navigating`): set on `<html>` for the duration of
68
+ a region swap. A swap commits the new markup *before* re-hydrating it, so
69
+ "present in the DOM" and "interactive" are two moments — until this clears, a
70
+ swapped-in island may still be server markup with no handlers, and a click on
71
+ it is lost. Style off it for a loading indicator, or wait for its absence
72
+ before driving new content. `NAVIGATING_ATTR` is exported so callers need not
73
+ hard-code the string. Query-only navigations swap nothing and never set it.
74
+
75
+ ## `<head>`: metadata is reconciled, resources are not
76
+
77
+ A region is a **body** subtree, so `<head>` is not swapped wholesale. It splits
78
+ in two, and the split is the whole contract.
79
+
80
+ ### Page metadata — reconciled on every swap, always
81
+
82
+ Page metadata is page-scoped by definition, so the router brings a closed
83
+ allowlist of it in line with the incoming document. This always runs and there
84
+ is no flag to disable it: a stale `<meta name="description">` is wrongness you
85
+ *cannot see* in development (unlike the tab title), and this package doesn't
86
+ leave that class opt-in.
87
+
88
+ | head node | key |
89
+ | --- | --- |
90
+ | `<title>` | — |
91
+ | `<meta name="description \| keywords \| robots \| author \| theme-color">` | `name` |
92
+ | `<meta property="og:*">` / `<meta name="twitter:*">` | `name` or `property` |
93
+ | `<link rel="canonical \| alternate \| prev \| next">` | `rel` + `hreflang`/`type`/`media` |
94
+
95
+ A key in both documents is replaced (skipped when the nodes are already equal,
96
+ so metadata shared across routes causes no DOM churn); a key only in the
97
+ incoming page is added; a key only in the live page is **removed**, so it can't
98
+ leak forward into every later route.
99
+
100
+ Anything whose key isn't in that table is never read, replaced, or removed —
101
+ runtime-injected analytics tags, CSP `<meta http-equiv>`, `<link rel=preconnect>`
102
+ and friends are safe by construction. (This is the deliberate difference from
103
+ Turbo, which removes every untracked head element.) Opt a node out with
104
+ `data-bf-head="false"` when the page itself owns it.
105
+
106
+ ### Head resources — untouched
107
+
108
+ `<link rel="stylesheet">`, `<script>`, and `<style>` in `<head>` are left alone
109
+ in both directions. Not an oversight: a resource's lifetime isn't derivable from
110
+ the incoming document. The shell, a `[data-bf-permanent]` node, a portal, or an
111
+ island that outlives the region may still depend on a sheet the next page's head
112
+ doesn't list, so "absent downstream" is no evidence of "no longer needed".
113
+
114
+ That makes a **route-scoped stylesheet in `<head>`** the one real trap:
115
+ navigating *into* the route renders it unstyled (a reload "fixes" it, which
116
+ points the investigation at caching or the build instead of at navigation), and
117
+ navigating *out* leaves the sheet linked, so its rules then apply to every route
118
+ after it.
119
+
120
+ Put it **inside** the region, where it enters and leaves with the swap:
121
+
122
+ ```tsx
123
+ <Region>
124
+ {isEditor ? <link rel="stylesheet" href="/editor.css" /> : null}
125
+ {children}
126
+ </Region>
127
+ ```
128
+
129
+ `rel="stylesheet"` is body-ok per HTML, so this is valid — and it is the right
130
+ placement under a region-swap contract, not a workaround. It gets both orderings
131
+ right *by construction* (the sheet is inserted with the content it styles and
132
+ removed with it), with no load awaited in the navigation path. Sheets that are
133
+ genuinely global stay in `<head>`, where never touching them is exactly what you
134
+ want.
65
135
 
66
136
  ## Scope
67
137
 
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.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { startRouter, navigate } from './router.ts';
1
+ export { startRouter, navigate, NAVIGATING_ATTR } from './router.ts';
2
2
  export type { RouterOptions, NavigateOptions, Router } from './types.ts';
3
3
  export { BF_REGION } from '@barefootjs/shared';
4
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AACnD,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAGxE,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AACpE,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAGxE,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA"}
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) {
@@ -343,6 +420,7 @@ function ensureAnnouncer() {
343
420
 
344
421
  // src/router.ts
345
422
  var active = null;
423
+ var NAVIGATING_ATTR = "data-bf-navigating";
346
424
  function startRouter(options = {}) {
347
425
  if (typeof document === "undefined" || typeof window === "undefined") {
348
426
  return { stop() {}, navigate: async () => {}, prefetch() {} };
@@ -487,6 +565,7 @@ async function navigate(url, options = {}) {
487
565
  state.inflight?.abort();
488
566
  const controller = new AbortController;
489
567
  state.inflight = controller;
568
+ setNavigating(true);
490
569
  try {
491
570
  const snap = await loadPage(state, target.href);
492
571
  if (controller.signal.aborted)
@@ -524,6 +603,7 @@ async function navigate(url, options = {}) {
524
603
  }
525
604
  if (title !== null)
526
605
  document.title = title;
606
+ reconcileHead(incomingDoc);
527
607
  if (plan.mode === "regions") {
528
608
  for (const [id, key] of plan.incomingKeys)
529
609
  state.regionBaselines.set(id, key);
@@ -555,10 +635,21 @@ async function navigate(url, options = {}) {
555
635
  announceNavigation(title);
556
636
  }
557
637
  } finally {
558
- if (state.inflight === controller)
638
+ if (state.inflight === controller) {
559
639
  state.inflight = null;
640
+ setNavigating(false);
641
+ }
560
642
  }
561
643
  }
644
+ function setNavigating(on) {
645
+ const root = typeof document !== "undefined" ? document.documentElement : null;
646
+ if (!root)
647
+ return;
648
+ if (on)
649
+ root.setAttribute(NAVIGATING_ATTR, "");
650
+ else
651
+ root.removeAttribute(NAVIGATING_ATTR);
652
+ }
562
653
  function prefetch(url) {
563
654
  const state = active;
564
655
  if (!state)
@@ -653,5 +744,6 @@ import { BF_REGION as BF_REGION3 } from "@barefootjs/shared";
653
744
  export {
654
745
  startRouter,
655
746
  navigate,
747
+ NAVIGATING_ATTR,
656
748
  BF_REGION3 as BF_REGION
657
749
  };
package/dist/router.d.ts CHANGED
@@ -8,6 +8,12 @@
8
8
  * the incoming ones, with last-wins semantics across overlapping navigations.
9
9
  */
10
10
  import type { NavigateOptions, Router, RouterOptions } from './types.ts';
11
+ /**
12
+ * Document-root attribute marking a region swap as in flight — see
13
+ * {@link setNavigating}. Public: a page may style off it (a loading bar), and a
14
+ * test may wait for its absence to know the swapped-in islands are live.
15
+ */
16
+ export declare const NAVIGATING_ATTR = "data-bf-navigating";
11
17
  export declare function startRouter(options?: RouterOptions): Router;
12
18
  export declare function navigate(url: string, options?: NavigateOptions): Promise<void>;
13
19
  //# sourceMappingURL=router.d.ts.map
@@ -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;;;;GAIG;AACH,eAAO,MAAM,eAAe,uBAAuB,CAAA;AAEnD,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,CA6KxF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/router",
3
- "version": "0.28.1",
3
+ "version": "0.30.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.1"
43
+ "@barefootjs/shared": "0.30.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.1",
54
+ "@barefootjs/client": "^0.30.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/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { startRouter, navigate } from './router.ts'
1
+ export { startRouter, navigate, NAVIGATING_ATTR } from './router.ts'
2
2
  export type { RouterOptions, NavigateOptions, Router } from './types.ts'
3
3
 
4
4
  // Re-exported so server-side helpers can reference the swappable-region marker.
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,
@@ -39,6 +40,13 @@ import type {
39
40
 
40
41
  let active: RouterState | null = null
41
42
 
43
+ /**
44
+ * Document-root attribute marking a region swap as in flight — see
45
+ * {@link setNavigating}. Public: a page may style off it (a loading bar), and a
46
+ * test may wait for its absence to know the swapped-in islands are live.
47
+ */
48
+ export const NAVIGATING_ATTR = 'data-bf-navigating'
49
+
42
50
  export function startRouter(options: RouterOptions = {}): Router {
43
51
  // SSR / non-DOM: a no-op handle, never throws.
44
52
  if (typeof document === 'undefined' || typeof window === 'undefined') {
@@ -237,6 +245,7 @@ export async function navigate(url: string, options: NavigateOptions = {}): Prom
237
245
  state.inflight?.abort()
238
246
  const controller = new AbortController()
239
247
  state.inflight = controller
248
+ setNavigating(true)
240
249
 
241
250
  try {
242
251
  const snap = await loadPage(state, target.href)
@@ -297,7 +306,16 @@ export async function navigate(url: string, options: NavigateOptions = {}): Prom
297
306
  current.replaceChildren(fragment)
298
307
  swapped.push({ region: current, outgoing })
299
308
  }
309
+ // Commit the page's identity: the title (also handed to the route
310
+ // announcement below) and the allowlisted `<head>` metadata — description,
311
+ // canonical, og:/twitter: (`reconcileHead`, #2438). Head *resources*
312
+ // (`<link rel="stylesheet">`, scripts) stay unmanaged by contract, not by
313
+ // oversight: their lifetime isn't derivable from the incoming document, so
314
+ // a route-scoped stylesheet belongs inside the region, where it enters and
315
+ // leaves with the swap. Both are ordering-free, so they sit with the
316
+ // synchronous swaps rather than in the awaited tail.
300
317
  if (title !== null) document.title = title
318
+ reconcileHead(incomingDoc)
301
319
 
302
320
  // Refresh the per-region baselines to the server render now displayed: from
303
321
  // the incoming keys (matched regions), else recaptured from the live DOM
@@ -352,10 +370,42 @@ export async function navigate(url: string, options: NavigateOptions = {}): Prom
352
370
  announceNavigation(title)
353
371
  }
354
372
  } finally {
355
- if (state.inflight === controller) state.inflight = null
373
+ // Only the CURRENT navigation may clear the flag. A superseded one reaches
374
+ // this block while its successor is still mid-swap, and clearing there
375
+ // would announce "interactive" over content that is still being rebuilt.
376
+ if (state.inflight === controller) {
377
+ state.inflight = null
378
+ setNavigating(false)
379
+ }
356
380
  }
357
381
  }
358
382
 
383
+ /**
384
+ * Mark a region swap as in flight on the document root
385
+ * (`data-bf-navigating`), so a caller can tell "the new markup is in the DOM"
386
+ * from "the new markup is INTERACTIVE".
387
+ *
388
+ * Those are two different moments and the gap between them is real: the swap
389
+ * is committed, and only then does step 5 re-hydrate each region — which
390
+ * `defaultRehydrate` may reach through a dynamic import of
391
+ * `@barefootjs/client/runtime` (`seams.ts`). Until that resolves the
392
+ * swapped-in islands are server markup with no handlers attached, so a click
393
+ * lands on nothing and is silently lost. Nothing observable distinguished the two states before this,
394
+ * which made "wait for the element, then click it" look correct and fail only
395
+ * under load.
396
+ *
397
+ * Set where the swap sequence begins and cleared in its `finally`, so it spans
398
+ * dispose → module load → history → re-hydrate → focus. Query-only
399
+ * navigations return before that sequence and never set it: they re-render
400
+ * nothing and swap nothing, so there is no interactivity gap to describe.
401
+ */
402
+ function setNavigating(on: boolean): void {
403
+ const root = typeof document !== 'undefined' ? document.documentElement : null
404
+ if (!root) return
405
+ if (on) root.setAttribute(NAVIGATING_ATTR, '')
406
+ else root.removeAttribute(NAVIGATING_ATTR)
407
+ }
408
+
359
409
  // --- Prefetch -------------------------------------------------------------
360
410
 
361
411
  function prefetch(url: string): void {