@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 +70 -0
- package/dist/head.d.ts +46 -0
- package/dist/head.d.ts.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +93 -1
- package/dist/router.d.ts +6 -0
- package/dist/router.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/head.ts +145 -0
- package/src/index.ts +1 -1
- package/src/router.ts +51 -1
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
package/dist/router.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 {
|