@webjsdev/core 0.7.49 → 0.7.51
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/dist/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +21 -21
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -7
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +1 -1
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/attribute-reader.js +178 -0
- package/src/component/lifecycle.js +1481 -0
- package/src/component/reactive.js +171 -0
- package/src/component/server-element.js +248 -0
- package/src/component.d.ts +10 -0
- package/src/component.js +2 -1931
- package/src/html-entities.js +2179 -0
- package/src/render-client/parts.js +2007 -0
- package/src/render-client/reconciler.js +388 -0
- package/src/render-client/template-compiler.js +525 -0
- package/src/render-client.js +1 -2939
- package/src/render-server/attrs.js +160 -0
- package/src/render-server/dsd.js +445 -0
- package/src/render-server/html-scan.js +402 -0
- package/src/render-server/slots.js +213 -0
- package/src/render-server/stream.js +163 -0
- package/src/render-server/template-renderer.js +923 -0
- package/src/render-server/text.js +163 -0
- package/src/render-server.js +6 -2334
- package/src/router-client/anchors.js +44 -0
- package/src/router-client/boundaries.js +221 -0
- package/src/router-client/constants.js +187 -0
- package/src/router-client/diagnostics.js +377 -0
- package/src/router-client/dom-differ.js +709 -0
- package/src/router-client/dom-parse.js +150 -0
- package/src/router-client/events.js +139 -0
- package/src/router-client/fetch-apply.js +387 -0
- package/src/router-client/form-encoder.js +135 -0
- package/src/router-client/frames.js +329 -0
- package/src/router-client/head-merge.js +344 -0
- package/src/router-client/nav-error.js +105 -0
- package/src/router-client/navigator.js +781 -0
- package/src/router-client/prefetch.js +824 -0
- package/src/router-client/scroll.js +259 -0
- package/src/router-client/snapshot-cache.js +97 -0
- package/src/router-client/state.js +92 -0
- package/src/router-client/stream.js +206 -0
- package/src/router-client/swap.js +451 -0
- package/src/router-client/upgrade.js +261 -0
- package/src/router-client/view-transition.js +223 -0
- package/src/router-client.d.ts +10 -1
- package/src/router-client.js +75 -5345
- package/src/slot/assignment.js +304 -0
- package/src/slot/interception.js +558 -0
- package/src/slot/polyfills.js +365 -0
- package/src/slot/project.js +432 -0
- package/src/slot/sensors.js +303 -0
- package/src/slot/state.js +245 -0
- package/src/slot/symbols.js +85 -0
- package/src/slot.js +7 -2199
- package/src/webjs-config.d.ts +13 -2
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding the anchor an event happened inside, across shadow boundaries.
|
|
3
|
+
*
|
|
4
|
+
* A leaf: both helpers are pure DOM walks over their argument. They lived in
|
|
5
|
+
* events.js, which made prefetch.js and upgrade.js import the router's event
|
|
6
|
+
* layer just to resolve an anchor, and that was one of the edges holding the
|
|
7
|
+
* directory in a cycle.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Find the nearest <a> in the event's composed path. composedPath() crosses
|
|
12
|
+
* shadow DOM boundaries: essential because nav links typically live inside
|
|
13
|
+
* the layout shell's shadow root.
|
|
14
|
+
*
|
|
15
|
+
* @param {MouseEvent} e
|
|
16
|
+
* @returns {HTMLAnchorElement | null}
|
|
17
|
+
*/
|
|
18
|
+
export function findAnchorInPath(e) {
|
|
19
|
+
for (const el of e.composedPath()) {
|
|
20
|
+
if (el instanceof HTMLAnchorElement) return el;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Nearest enclosing <a>, crossing shadow boundaries, from an event
|
|
26
|
+
* target. composedPath is click-only, so walk getRootNode().host here.
|
|
27
|
+
*
|
|
28
|
+
* @param {EventTarget | null} target
|
|
29
|
+
* @returns {HTMLAnchorElement | null}
|
|
30
|
+
*/
|
|
31
|
+
export function closestAnchor(target) {
|
|
32
|
+
let node = /** @type {Node | null} */ (target);
|
|
33
|
+
while (node) {
|
|
34
|
+
if (node instanceof HTMLAnchorElement) return node;
|
|
35
|
+
const el = node.nodeType === 1 ? /** @type {Element} */ (node) : null;
|
|
36
|
+
if (el) {
|
|
37
|
+
const a = el.closest && el.closest('a');
|
|
38
|
+
if (a instanceof HTMLAnchorElement) return a;
|
|
39
|
+
}
|
|
40
|
+
const root = node.getRootNode ? node.getRootNode() : null;
|
|
41
|
+
node = root && /** @type any */ (root).host ? /** @type any */ (root).host : null;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client router: boundaries.
|
|
3
|
+
*
|
|
4
|
+
* Moved verbatim out of the pre-split `router-client.js`; that barrel holds
|
|
5
|
+
* the router's full contract and the public entry points.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Walk a node tree collecting KEYED children-boundary pairs into a Map
|
|
12
|
+
* keyed by segment path.
|
|
13
|
+
*
|
|
14
|
+
* Boundaries are HTML comments emitted by SSR around each layout's
|
|
15
|
+
* children interpolation AND around the page itself:
|
|
16
|
+
* <!--wj:children:/docs:/docs--> (open: segment + route-key)
|
|
17
|
+
* <page content>
|
|
18
|
+
* <!--/wj:children:/docs--> (close: segment)
|
|
19
|
+
*
|
|
20
|
+
* The close carries the SEGMENT, so pairing is deterministic id-matching:
|
|
21
|
+
* a close must match the segment of the INNERMOST open boundary (proper
|
|
22
|
+
* nesting), never a positional LIFO guess. The open additionally carries
|
|
23
|
+
* the resolved ROUTE-KEY (param values percent-encoded at emit), which
|
|
24
|
+
* `planBoundarySwap` compares between the live and incoming DOM to pick
|
|
25
|
+
* the swap tier.
|
|
26
|
+
*
|
|
27
|
+
* STRICT INTEGRITY, by design (#1015): this scanner never guesses. ANY
|
|
28
|
+
* violation poisons the whole scan and returns null:
|
|
29
|
+
* - an open with no route-key (the legacy anonymous format, or truncation)
|
|
30
|
+
* - a close whose segment does not match the innermost open boundary
|
|
31
|
+
* - a close with no open boundary at all
|
|
32
|
+
* - a duplicate segment (two boundaries claiming one id)
|
|
33
|
+
* - an open boundary never closed (truncated response)
|
|
34
|
+
* The caller degrades a poisoned side to a FULL PAGE LOAD: bounded, correct,
|
|
35
|
+
* and honest, where the deleted heuristic recovery (#994's orphan recovery +
|
|
36
|
+
* trailing-count bounding) could guess wrong and corrupt silently. The main
|
|
37
|
+
* PRODUCER of mispairing (our own comment-stripping parse, #1007, and
|
|
38
|
+
* mid-parse soft navs, #1008) is already fixed upstream, so poisoning is a
|
|
39
|
+
* rare backstop, not a common path.
|
|
40
|
+
*
|
|
41
|
+
* @param {ParentNode} root
|
|
42
|
+
* @returns {Map<string, { routeKey: string, start: Comment, end: Comment }> | null}
|
|
43
|
+
* The boundary map, or null when the tree's boundaries are malformed.
|
|
44
|
+
*/
|
|
45
|
+
export function collectBoundaries(root) {
|
|
46
|
+
/** @type {Map<string, { routeKey: string, start: Comment, end: Comment }>} */
|
|
47
|
+
const out = new Map();
|
|
48
|
+
/** @type {{ segment: string, routeKey: string, start: Comment }[]} */
|
|
49
|
+
const stack = [];
|
|
50
|
+
let poisoned = false;
|
|
51
|
+
|
|
52
|
+
// Plain recursive comment walk: TreeWalker/NodeFilter aren't available
|
|
53
|
+
// in every DOM polyfill (notably linkedom in tests). Iterative depth-
|
|
54
|
+
// first traversal keeps us portable across linkedom + native + jsdom.
|
|
55
|
+
/** @param {Node} node */
|
|
56
|
+
function visit(node) {
|
|
57
|
+
if (poisoned) return;
|
|
58
|
+
if (node.nodeType === 8 /* COMMENT_NODE */) {
|
|
59
|
+
const data = /** @type {Comment} */ (node).data.trim();
|
|
60
|
+
if (data.startsWith('wj:children:')) {
|
|
61
|
+
const rest = data.slice('wj:children:'.length);
|
|
62
|
+
// The route-key is everything after the LAST ':'. Substituted param
|
|
63
|
+
// values are percent-encoded at emit and static pieces have their
|
|
64
|
+
// delimiter characters encoded too, so the last colon is unambiguous
|
|
65
|
+
// for framework-emitted boundaries. A hand-authored folder name that
|
|
66
|
+
// still smuggles a delimiter through can only MIS-SPLIT here, which
|
|
67
|
+
// mismatches the close and poisons the scan: degrade-only, never a
|
|
68
|
+
// wrong pairing.
|
|
69
|
+
const cut = rest.lastIndexOf(':');
|
|
70
|
+
if (cut <= 0 || cut === rest.length - 1) { poisoned = true; return; }
|
|
71
|
+
const segment = rest.slice(0, cut);
|
|
72
|
+
const routeKey = rest.slice(cut + 1);
|
|
73
|
+
if (out.has(segment) || stack.some((f) => f.segment === segment)) {
|
|
74
|
+
poisoned = true;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
stack.push({ segment, routeKey, start: /** @type {Comment} */ (node) });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (data.startsWith('/wj:children')) {
|
|
81
|
+
const seg = data.slice('/wj:children'.length).replace(/^:/, '');
|
|
82
|
+
const frame = stack.pop();
|
|
83
|
+
if (!frame || frame.segment !== seg) { poisoned = true; return; }
|
|
84
|
+
// Same-parent integrity: HTML parser reparenting (a <p> auto-closed
|
|
85
|
+
// by block content) can split a pair across parents. The range
|
|
86
|
+
// operations walk nextSibling from start and insert before end, so a
|
|
87
|
+
// cross-parent pair would empty the region and then throw mid-swap.
|
|
88
|
+
// Poison instead: degrade up front.
|
|
89
|
+
if (frame.start.parentNode !== /** @type {Comment} */ (node).parentNode) {
|
|
90
|
+
poisoned = true;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// Table-context integrity: foster-parenting moves CONTENT out of the
|
|
94
|
+
// table while comment tokens stay put, so a boundary emitted in table
|
|
95
|
+
// context shares a parent (passing the check above) while its actual
|
|
96
|
+
// children were fostered OUTSIDE the range. Swapping that empty range
|
|
97
|
+
// would silently leave stale visible content. A `${children}` slot
|
|
98
|
+
// directly in table context cannot work as a swap boundary at all,
|
|
99
|
+
// so poison it.
|
|
100
|
+
{
|
|
101
|
+
const pt = /** @type {Element} */ (frame.start.parentNode).tagName;
|
|
102
|
+
if (pt === 'TABLE' || pt === 'TBODY' || pt === 'THEAD' || pt === 'TFOOT' || pt === 'TR') {
|
|
103
|
+
poisoned = true;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
out.set(frame.segment, {
|
|
108
|
+
routeKey: frame.routeKey,
|
|
109
|
+
start: frame.start,
|
|
110
|
+
end: /** @type {Comment} */ (node),
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (node.hasChildNodes && node.hasChildNodes()) {
|
|
117
|
+
for (let child = node.firstChild; child && !poisoned; child = child.nextSibling) {
|
|
118
|
+
visit(child);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
visit(/** @type {Node} */ (root));
|
|
123
|
+
|
|
124
|
+
if (poisoned || stack.length > 0) return null;
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Plan the two-tier boundary swap from the live + incoming boundary maps
|
|
130
|
+
* (#1015). Boundary segments are nested path prefixes, so the shared segments
|
|
131
|
+
* form a chain from `/` down to the deepest shared boundary D.
|
|
132
|
+
*
|
|
133
|
+
* Rules (Next.js remount-vs-preserve parity):
|
|
134
|
+
* - A changed route-key REPLACES at the PARENT of the shallowest changed
|
|
135
|
+
* boundary. The parent, not the changed boundary itself: a LAYOUT's
|
|
136
|
+
* boundary wraps its CHILDREN slot, so the layout's OWN markup (an
|
|
137
|
+
* `[org]`-name header it renders around `${children}`) lives inside the
|
|
138
|
+
* PARENT's range. Anchoring at the parent remounts the changed layout's
|
|
139
|
+
* chrome AND its subtree, exactly like Next re-rendering the layout with
|
|
140
|
+
* new params. The page boundary composes the same way: its parent is the
|
|
141
|
+
* nearest layout's children slot, so `/blog/a` -> `/blog/b` under a
|
|
142
|
+
* `/blog` layout remounts just the page while the `/blog` layout chrome
|
|
143
|
+
* is preserved; with no intermediate layout the anchor is `/` and the
|
|
144
|
+
* root layout's chrome (outside its own children boundary) is still
|
|
145
|
+
* preserved. A changed boundary with NO shared parent degrades (null).
|
|
146
|
+
* - No route-key changed but the subtree below D diverges (D is a LAYOUT,
|
|
147
|
+
* not the deepest boundary, on either side, e.g. `/about` -> `/contact`
|
|
148
|
+
* under a shared static root layout): REPLACE D's contents wholesale.
|
|
149
|
+
* - No route-key changed and D is the deepest boundary on BOTH sides: MORPH
|
|
150
|
+
* D. The searchParams-only / refresh / revalidate nav, which must preserve
|
|
151
|
+
* hydrated component state while updating searchParam-driven DOM.
|
|
152
|
+
* - No shared segment at all: null (the caller degrades to a full load). In
|
|
153
|
+
* practice the page boundary exists on both sides of any same-app nav, so
|
|
154
|
+
* this is reached only for a divergent or malformed shell.
|
|
155
|
+
*
|
|
156
|
+
* @param {Map<string, { routeKey: string, start: Comment, end: Comment }>} here
|
|
157
|
+
* @param {Map<string, { routeKey: string, start: Comment, end: Comment }>} there
|
|
158
|
+
* @returns {{ mode: 'replace' | 'morph', segment: string,
|
|
159
|
+
* live: { routeKey: string, start: Comment, end: Comment },
|
|
160
|
+
* incoming: { routeKey: string, start: Comment, end: Comment } } | null}
|
|
161
|
+
*/
|
|
162
|
+
export function planBoundarySwap(here, there) {
|
|
163
|
+
// Shared segments, shallowest first (a nested path prefix is shorter).
|
|
164
|
+
const shared = [...here.keys()].filter((s) => there.has(s)).sort((a, b) => a.length - b.length);
|
|
165
|
+
if (shared.length === 0) return null;
|
|
166
|
+
// A changed route-key remounts at the PARENT of the shallowest change.
|
|
167
|
+
for (const seg of shared) {
|
|
168
|
+
if (here.get(seg).routeKey !== there.get(seg).routeKey) {
|
|
169
|
+
let parent = null;
|
|
170
|
+
for (const p of shared) {
|
|
171
|
+
if (p === seg) break;
|
|
172
|
+
if (seg.startsWith(p === '/' ? p : p + '/')) parent = p;
|
|
173
|
+
}
|
|
174
|
+
if (!parent) return null; // no anchored parent: degrade
|
|
175
|
+
return { mode: 'replace', segment: parent, live: here.get(parent), incoming: there.get(parent) };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// No route-key changed. D = deepest shared boundary.
|
|
179
|
+
const D = shared[shared.length - 1];
|
|
180
|
+
/** @param {Map<string, unknown>} m */
|
|
181
|
+
const deepestOf = (m) => {
|
|
182
|
+
let best = null;
|
|
183
|
+
for (const s of m.keys()) if (best === null || s.length > best.length) best = s;
|
|
184
|
+
return best;
|
|
185
|
+
};
|
|
186
|
+
const leafOnBoth = deepestOf(here) === D && deepestOf(there) === D;
|
|
187
|
+
return {
|
|
188
|
+
mode: leafOnBoth ? 'morph' : 'replace',
|
|
189
|
+
segment: D,
|
|
190
|
+
live: here.get(D),
|
|
191
|
+
incoming: there.get(D),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* ====================================================================
|
|
196
|
+
* Snapshot cache (Turbo SnapshotCache pattern)
|
|
197
|
+
* ==================================================================== */
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Build the X-Webjs-Have header value from the live DOM's boundaries.
|
|
201
|
+
* Comma-separated `<segment>:<route-key>` entries in document order. The
|
|
202
|
+
* ROUTE-KEY rides along so the server's short-circuit can distinguish "the
|
|
203
|
+
* client has this layout" from "the client has this layout rendered for
|
|
204
|
+
* DIFFERENT params": a dynamic `[org]` layout the client holds for org-a
|
|
205
|
+
* must be re-rendered (and re-shipped) on an org-b navigation, or the
|
|
206
|
+
* parent-anchored REPLACE would have no fresh layout markup to swap in.
|
|
207
|
+
* The server ignores the page-boundary entry (never a layout segment).
|
|
208
|
+
*
|
|
209
|
+
* A poisoned live DOM (malformed boundaries) reports an EMPTY have, so the
|
|
210
|
+
* server returns the full page. The subsequent applySwap re-scans, sees the
|
|
211
|
+
* same poisoned live tree, and degrades to a full load: consistent with the
|
|
212
|
+
* integrity-gate model, never a reduced fragment spliced against a tree we
|
|
213
|
+
* cannot trust (#1015).
|
|
214
|
+
*
|
|
215
|
+
* @returns {string}
|
|
216
|
+
*/
|
|
217
|
+
export function buildHaveHeader() {
|
|
218
|
+
const boundaries = collectBoundaries(document.body);
|
|
219
|
+
if (!boundaries) return '';
|
|
220
|
+
return [...boundaries.entries()].map(([seg, b]) => `${seg}:${b.routeKey}`).join(',');
|
|
221
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client router: constants.
|
|
3
|
+
*
|
|
4
|
+
* Moved verbatim out of the pre-split `router-client.js`; that barrel holds
|
|
5
|
+
* the router's full contract and the public entry points.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The content type a content-negotiated stream-action response carries (#248). */
|
|
11
|
+
export const STREAM_MIME = 'text/vnd.webjs-stream.html';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Client router for webjs: nested-layout-aware partial swap.
|
|
15
|
+
*
|
|
16
|
+
* Intercepts same-origin link clicks and form submissions, fetches the
|
|
17
|
+
* target page's HTML via `fetch()`, finds the deepest layout boundary
|
|
18
|
+
* shared by both the current and incoming pages, and replaces ONLY the
|
|
19
|
+
* children of that boundary. Outer layout DOM (header, sidenav, footer)
|
|
20
|
+
* stays mounted: no re-render, no flicker, scroll positions preserved.
|
|
21
|
+
*
|
|
22
|
+
* Enablement is automatic: this module calls `enableClientRouter()` at its
|
|
23
|
+
* end (idempotent), and the `@webjsdev/core` browser entry loads it, so any
|
|
24
|
+
* page that ships a component gets the router with no import to add. Call
|
|
25
|
+
* `disableClientRouter()` to opt out, or `enableClientRouter()` for
|
|
26
|
+
* programmatic control.
|
|
27
|
+
*
|
|
28
|
+
* Mechanism: auto-derived from folder structure (#1015):
|
|
29
|
+
* 1. SSR injects KEYED boundary comment pairs around each layout's
|
|
30
|
+
* `${children}` interpolation and around the page itself:
|
|
31
|
+
* `<!--wj:children:<segment>:<route-key>-->` ... `<!--/wj:children:<segment>-->`.
|
|
32
|
+
* The close carries the segment (deterministic id-matched pairing, no
|
|
33
|
+
* LIFO), the open carries the resolved route-key (param values
|
|
34
|
+
* percent-encoded).
|
|
35
|
+
* 2. On link click, STRICTLY scan both the live DOM and the incoming HTML
|
|
36
|
+
* into segment → {routeKey, range} maps. Any pairing violation poisons
|
|
37
|
+
* the scan.
|
|
38
|
+
* 3. Two-tier DECISION (Next.js remount parity), nothing applied yet: a
|
|
39
|
+
* CHANGED route-key REPLACES (fresh remount) at the PARENT of the
|
|
40
|
+
* shallowest changed boundary (whose range contains the changed layout's
|
|
41
|
+
* own markup), else MORPH (keyed reconcile preserving input values,
|
|
42
|
+
* scroll, popover state, and node identity) at the deepest shared
|
|
43
|
+
* boundary.
|
|
44
|
+
* 4. A poisoned scan or no shared boundary degrades to a FULL PAGE LOAD:
|
|
45
|
+
* bounded and correct, never a guessed recovery.
|
|
46
|
+
* 5. Commit, in this order: `history.pushState`, then merge head, then apply
|
|
47
|
+
* the replace/morph from step 3, then re-run scripts and upgrade custom
|
|
48
|
+
* elements. Only the push moved (#1406); the rest of the sequence is
|
|
49
|
+
* unchanged. The push leads because
|
|
50
|
+
* WebKit binds a same-document entry's
|
|
51
|
+
* back-forward gesture snapshot to the page state when the entry is
|
|
52
|
+
* recorded, so recording it after the content swap makes an iOS
|
|
53
|
+
* back-swipe preview the destination instead of the page being returned
|
|
54
|
+
* to (#1406).
|
|
55
|
+
*
|
|
56
|
+
* Optimizations bundled into the same response cycle:
|
|
57
|
+
* - `X-Webjs-Have` request header lists `segment:route-key` entries for
|
|
58
|
+
* the boundaries the client already has. The server walks the target's
|
|
59
|
+
* layout chain and short-circuits at the deepest FULL match (segment AND
|
|
60
|
+
* key, so a dynamic layout held for other params is re-rendered), then
|
|
61
|
+
* returns only the divergent fragment (wrapped in the matched boundary).
|
|
62
|
+
* Real wire-byte savings: the layout chain is never re-serialized for
|
|
63
|
+
* same-shell navigations.
|
|
64
|
+
* - URL-keyed snapshot cache (Turbo SnapshotCache pattern). Back/
|
|
65
|
+
* forward via popstate restores from cache instantly, then
|
|
66
|
+
* revalidates in the background.
|
|
67
|
+
* - Per-segment loading templates: SSR emits each segment's
|
|
68
|
+
* loading.ts content as `<template id="wj-loading:<path>">`. On
|
|
69
|
+
* nav-start the client clones the deepest matching template into
|
|
70
|
+
* the swap slot so users see an instant skeleton instead of stale
|
|
71
|
+
* content.
|
|
72
|
+
*
|
|
73
|
+
* Escape hatch:
|
|
74
|
+
* `<webjs-frame id="...">`: declarative partial-swap region NOT
|
|
75
|
+
* tied to a folder layout. If a link's enclosing `closest('webjs-frame')`
|
|
76
|
+
* matches a frame in the incoming HTML, the frame swap takes
|
|
77
|
+
* precedence over the layout-marker mechanism. Use for ad-hoc
|
|
78
|
+
* widgets (tabs, lazy-loaded cards) where the swap region isn't a
|
|
79
|
+
* folder route segment.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Hard ceiling on the restore window (#1310). The revalidation is a
|
|
84
|
+
* same-origin GET of a page the browser rendered moments ago, so this is
|
|
85
|
+
* generously past its p99. It exists only so a hung or never-settling fetch
|
|
86
|
+
* can never leave scroll anchoring suppressed for the life of the page.
|
|
87
|
+
*/
|
|
88
|
+
export const ANCHOR_SUPPRESS_CEILING_MS = 2000;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Floor on the restore window (#1310). The window's other closer is the
|
|
92
|
+
* revalidation settling, which is only long enough while the revalidation is
|
|
93
|
+
* SLOWER than the restored page's own upgrade-and-render. That holds on a
|
|
94
|
+
* deployed site (measured: growth ~65ms after the swap, the revalidation's swap
|
|
95
|
+
* ~300ms after that) but it is a property of one deployment, not a guarantee: a
|
|
96
|
+
* local server, a 304, or a warm cache answers in single-digit milliseconds and
|
|
97
|
+
* would otherwise close the window before the growth it exists to absorb.
|
|
98
|
+
*
|
|
99
|
+
* So the window lasts at least this long whatever the network does. The value
|
|
100
|
+
* clears the measured revalidation swap with margin and stays well under the
|
|
101
|
+
* ceiling. It is a floor, not a delay: a real user input still closes the
|
|
102
|
+
* window immediately, which is the case that actually matters for not holding
|
|
103
|
+
* anchoring off longer than a reader would want.
|
|
104
|
+
*/
|
|
105
|
+
export const ANCHOR_SUPPRESS_FLOOR_MS = 500;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Inputs that mean the reader has taken over the viewport, so the restore is
|
|
109
|
+
* over and the browser's own anchoring should resume.
|
|
110
|
+
*
|
|
111
|
+
* NOT `scroll`. The router's own `scrollTo` and anchoring itself both fire
|
|
112
|
+
* `scroll`, so it cannot tell a reader apart from the restore it is guarding,
|
|
113
|
+
* and no threshold makes it able to. These are input events, so there is
|
|
114
|
+
* nothing to threshold out and the FIRST one closes the window. `keydown` is
|
|
115
|
+
* deliberately not narrowed to scrolling keys: any keypress means interaction,
|
|
116
|
+
* and closing early only restores the browser default, which is the safe
|
|
117
|
+
* direction to err in.
|
|
118
|
+
*
|
|
119
|
+
* @type {string[]}
|
|
120
|
+
*/
|
|
121
|
+
export const ANCHOR_RELEASE_EVENTS = ['wheel', 'touchmove', 'keydown', 'pointerdown'];
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Pathnames with these extensions are never HTML pages.
|
|
125
|
+
*/
|
|
126
|
+
export const NON_HTML_EXTENSIONS = /\.(?:pdf|zip|tar|gz|7z|rar|dmg|exe|msi|deb|rpm|apk|ipa|xlsx?|docx?|pptx?|csv|odt|ods|odp|rtf|epub|mobi|xml|json|rss|atom|txt|md|wasm|mp3|mp4|mov|avi|webm|ogg|flac|wav|m4a|m4v|mkv|png|jpe?g|gif|webp|avif|bmp|ico|svg|tiff?|heic)$/i;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The reserved `data-webjs-frame` token that forces a full-page navigation,
|
|
130
|
+
* breaking OUT of any enclosing frame (Turbo's `data-turbo-frame="_top"`).
|
|
131
|
+
* `resolveTargetFrameId` returns this sentinel; callers treat it exactly
|
|
132
|
+
* like "no frame" (a normal layout-marker / full-body swap), so a trigger
|
|
133
|
+
* physically nested in a frame escapes the frame swap. Distinct from `null`
|
|
134
|
+
* only inside `resolveTargetFrameId` (where `null` would otherwise fall back
|
|
135
|
+
* to the enclosing frame); both reach `performNavigation` as a frameless
|
|
136
|
+
* nav, so they behave identically downstream.
|
|
137
|
+
*/
|
|
138
|
+
export const FRAME_TOP = '_top';
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* `sessionStorage` key holding the destination of a full load the ROUTER
|
|
142
|
+
* itself chose (#1118). Written by `reportFallback` when `willReload` is true,
|
|
143
|
+
* consumed once by the next document's boot. Per-tab and cleared with the tab,
|
|
144
|
+
* which is the right lifetime for a marker about one navigation.
|
|
145
|
+
*/
|
|
146
|
+
export const FALLBACK_MARKER_KEY = 'webjs:nav-fallback';
|
|
147
|
+
|
|
148
|
+
export const SNAPSHOT_CAP = 16;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Attribute names whose live DOM state must NEVER be overwritten by
|
|
152
|
+
* incoming server HTML during a partial swap. The server emits these
|
|
153
|
+
* with their initial-render value; the user may have typed/clicked
|
|
154
|
+
* between renders. Preserving them keeps focus, typing, open state,
|
|
155
|
+
* and popover state intact across navigation.
|
|
156
|
+
*/
|
|
157
|
+
export const LIVE_ATTRS = new Set([
|
|
158
|
+
// Form controls
|
|
159
|
+
'value', 'checked', 'selected', 'indeterminate', 'disabled',
|
|
160
|
+
// Disclosure / popover
|
|
161
|
+
'open', 'popover',
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
/* ====================================================================
|
|
165
|
+
* Optimistic loading (per-segment loading.ts templates)
|
|
166
|
+
* ==================================================================== */
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The one framework-owned keyed meta that must NEVER be reconciled: the CSP
|
|
170
|
+
* nonce. A soft-nav response carries a FRESH per-request nonce, but the browser
|
|
171
|
+
* enforces CSP against the nonce the ORIGINAL page load declared (see
|
|
172
|
+
* `getCspNonce`), so overwriting the live `csp-nonce` meta with the incoming
|
|
173
|
+
* one would make every later nonce-stamped script/preload violate the active
|
|
174
|
+
* policy. Excluded from add/update/remove so the original meta survives verbatim.
|
|
175
|
+
*/
|
|
176
|
+
export const META_KEY_CSP_NONCE = 'name=csp-nonce';
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Predicate used by the onClick handler to decide whether a same-origin
|
|
180
|
+
* href should bypass the router. Exposed for unit testing.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} pathname
|
|
183
|
+
* @returns {boolean}
|
|
184
|
+
*/
|
|
185
|
+
export function _isNonHtmlPath(pathname) {
|
|
186
|
+
return NON_HTML_EXTENSIONS.test(pathname);
|
|
187
|
+
}
|