@webjsdev/core 0.7.50 → 0.7.53

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.
Files changed (64) hide show
  1. package/dist/webjs-core-browser.js +5 -5
  2. package/dist/webjs-core-browser.js.map +3 -3
  3. package/dist/webjs-core-testing.js +20 -20
  4. package/dist/webjs-core-testing.js.map +3 -3
  5. package/dist/webjs-core.js +7 -7
  6. package/dist/webjs-core.js.map +3 -3
  7. package/index-browser.js +1 -1
  8. package/index.d.ts +3 -3
  9. package/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/component/lifecycle.js +1481 -0
  12. package/src/component/reactive.js +171 -0
  13. package/src/component/server-element.js +248 -0
  14. package/src/component.js +2 -1896
  15. package/src/css.d.ts +9 -0
  16. package/src/escape.d.ts +2 -0
  17. package/src/html.d.ts +9 -0
  18. package/src/render-client/parts.js +2039 -0
  19. package/src/render-client/reconciler.js +388 -0
  20. package/src/render-client/template-compiler.js +525 -0
  21. package/src/render-client.js +1 -2938
  22. package/src/render-server/attrs.js +160 -0
  23. package/src/render-server/dsd.js +445 -0
  24. package/src/render-server/html-scan.js +402 -0
  25. package/src/render-server/slots.js +213 -0
  26. package/src/render-server/stream.js +163 -0
  27. package/src/render-server/template-renderer.js +938 -0
  28. package/src/render-server/text.js +163 -0
  29. package/src/render-server.js +6 -2405
  30. package/src/repeat.d.ts +15 -0
  31. package/src/rich-fetch.d.ts +4 -0
  32. package/src/router-client/anchors.js +44 -0
  33. package/src/router-client/boundaries.js +221 -0
  34. package/src/router-client/constants.js +206 -0
  35. package/src/router-client/diagnostics.js +377 -0
  36. package/src/router-client/dom-differ.js +709 -0
  37. package/src/router-client/dom-parse.js +150 -0
  38. package/src/router-client/events.js +182 -0
  39. package/src/router-client/fetch-apply.js +432 -0
  40. package/src/router-client/form-encoder.js +135 -0
  41. package/src/router-client/frames.js +329 -0
  42. package/src/router-client/head-merge.js +344 -0
  43. package/src/router-client/nav-error.js +105 -0
  44. package/src/router-client/navigator.js +952 -0
  45. package/src/router-client/prefetch.js +831 -0
  46. package/src/router-client/scroll.js +308 -0
  47. package/src/router-client/snapshot-cache.js +113 -0
  48. package/src/router-client/state.js +155 -0
  49. package/src/router-client/stream.js +206 -0
  50. package/src/router-client/swap.js +454 -0
  51. package/src/router-client/upgrade.js +261 -0
  52. package/src/router-client/view-transition.js +223 -0
  53. package/src/router-client.d.ts +11 -2
  54. package/src/router-client.js +78 -5345
  55. package/src/slot/assignment.js +304 -0
  56. package/src/slot/interception.js +558 -0
  57. package/src/slot/polyfills.js +365 -0
  58. package/src/slot/project.js +432 -0
  59. package/src/slot/sensors.js +303 -0
  60. package/src/slot/state.js +245 -0
  61. package/src/slot/symbols.js +85 -0
  62. package/src/slot.js +7 -2199
  63. package/src/suspense.d.ts +9 -0
  64. package/src/websocket-client.d.ts +22 -0
@@ -0,0 +1,15 @@
1
+ // The runtime value also carries a module-private `Symbol.for('webjs.repeat')`
2
+ // key, the marker the renderers check. It is deliberately absent here: it is not
3
+ // exported, so it cannot be named, and no consumer constructs one by hand.
4
+ export interface RepeatDirective<T> {
5
+ items: T[];
6
+ keyFn: (item: T, i: number) => string | number;
7
+ templateFn: (item: T, i: number) => unknown;
8
+ }
9
+
10
+ export function repeat<T>(
11
+ items: Iterable<T>,
12
+ keyFn: (item: T, i: number) => string | number,
13
+ templateFn: (item: T, i: number) => unknown,
14
+ ): RepeatDirective<T>;
15
+ export function isRepeat(x: unknown): x is RepeatDirective<unknown>;
@@ -0,0 +1,4 @@
1
+ // `body` is widened off `RequestInit` on purpose: richFetch also accepts a plain
2
+ // object, which it serializes with the WebJs wire format. `Omit` first, because
3
+ // an intersection would narrow the property back to `BodyInit | null`.
4
+ export function richFetch<T>(url: string | URL, init?: Omit<RequestInit, 'body'> & { body?: unknown }): Promise<T>;
@@ -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,206 @@
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 so the entry for the page being left is
50
+ * finalized while that page is still the live document, rather than
51
+ * against the content that replaced it. This is Turbo Drive's ordering
52
+ * too.
53
+ *
54
+ * #1406 justified the move as the fix for the blank iOS back-swipe
55
+ * preview, on the theory that WebKit binds the gesture snapshot to page
56
+ * state at the moment the entry is recorded. That theory is FALSE, shown
57
+ * on-device in #1428: Turbo Drive has this exact ordering and previews
58
+ * blank the same way, and the real cause was
59
+ * `history.scrollRestoration = 'manual'` suppressing the browser's
60
+ * per-entry scroll recording. The ordering stands on its own merits;
61
+ * it just never fixed that bug.
62
+ *
63
+ * Optimizations bundled into the same response cycle:
64
+ * - `X-Webjs-Have` request header lists `segment:route-key` entries for
65
+ * the boundaries the client already has. The server walks the target's
66
+ * layout chain and short-circuits at the deepest FULL match (segment AND
67
+ * key, so a dynamic layout held for other params is re-rendered), then
68
+ * returns only the divergent fragment (wrapped in the matched boundary).
69
+ * Real wire-byte savings: the layout chain is never re-serialized for
70
+ * same-shell navigations.
71
+ * - URL-keyed snapshot cache (Turbo SnapshotCache pattern). Back/
72
+ * forward via popstate restores from cache instantly, then
73
+ * revalidates in the background.
74
+ * - Per-segment loading templates: SSR emits each segment's
75
+ * loading.ts content as `<template id="wj-loading:<path>">`. On
76
+ * nav-start the client clones the deepest matching template into
77
+ * the swap slot so users see an instant skeleton instead of stale
78
+ * content.
79
+ *
80
+ * Escape hatch:
81
+ * `<webjs-frame id="...">`: declarative partial-swap region NOT
82
+ * tied to a folder layout. If a link's enclosing `closest('webjs-frame')`
83
+ * matches a frame in the incoming HTML, the frame swap takes
84
+ * precedence over the layout-marker mechanism. Use for ad-hoc
85
+ * widgets (tabs, lazy-loaded cards) where the swap region isn't a
86
+ * folder route segment.
87
+ */
88
+
89
+ /**
90
+ * Hard ceiling on the restore window (#1310). The revalidation is a
91
+ * same-origin GET of a page the browser rendered moments ago, so this is
92
+ * generously past its p99. It exists only so a hung or never-settling fetch
93
+ * can never leave scroll anchoring suppressed for the life of the page.
94
+ */
95
+ export const ANCHOR_SUPPRESS_CEILING_MS = 2000;
96
+
97
+ /**
98
+ * Floor on the restore window (#1310). The window's other closer is the
99
+ * revalidation settling, which is only long enough while the revalidation is
100
+ * SLOWER than the restored page's own upgrade-and-render. That holds on a
101
+ * deployed site (measured: growth ~65ms after the swap, the revalidation's swap
102
+ * ~300ms after that) but it is a property of one deployment, not a guarantee: a
103
+ * local server, a 304, or a warm cache answers in single-digit milliseconds and
104
+ * would otherwise close the window before the growth it exists to absorb.
105
+ *
106
+ * So the window lasts at least this long whatever the network does. The value
107
+ * clears the measured revalidation swap with margin and stays well under the
108
+ * ceiling. It is a floor, not a delay: a real user input still closes the
109
+ * window immediately, which is the case that actually matters for not holding
110
+ * anchoring off longer than a reader would want.
111
+ */
112
+ export const ANCHOR_SUPPRESS_FLOOR_MS = 500;
113
+
114
+ /**
115
+ * Inputs that mean the reader has taken over the viewport, so the restore is
116
+ * over and the browser's own anchoring should resume.
117
+ *
118
+ * NOT `scroll`. The router's own `scrollTo` and anchoring itself both fire
119
+ * `scroll`, so it cannot tell a reader apart from the restore it is guarding,
120
+ * and no threshold makes it able to. These are input events, so there is
121
+ * nothing to threshold out and the FIRST one closes the window. `keydown` is
122
+ * deliberately not narrowed to scrolling keys: any keypress means interaction,
123
+ * and closing early only restores the browser default, which is the safe
124
+ * direction to err in.
125
+ *
126
+ * @type {string[]}
127
+ */
128
+ export const ANCHOR_RELEASE_EVENTS = ['wheel', 'touchmove', 'keydown', 'pointerdown'];
129
+
130
+ /**
131
+ * Pathnames with these extensions are never HTML pages.
132
+ */
133
+ 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;
134
+
135
+ /**
136
+ * The reserved `data-webjs-frame` token that forces a full-page navigation,
137
+ * breaking OUT of any enclosing frame (Turbo's `data-turbo-frame="_top"`).
138
+ * `resolveTargetFrameId` returns this sentinel; callers treat it exactly
139
+ * like "no frame" (a normal layout-marker / full-body swap), so a trigger
140
+ * physically nested in a frame escapes the frame swap. Distinct from `null`
141
+ * only inside `resolveTargetFrameId` (where `null` would otherwise fall back
142
+ * to the enclosing frame); both reach `performNavigation` as a frameless
143
+ * nav, so they behave identically downstream.
144
+ */
145
+ export const FRAME_TOP = '_top';
146
+
147
+ /**
148
+ * `sessionStorage` key holding the destination of a full load the ROUTER
149
+ * itself chose (#1118). Written by `reportFallback` when `willReload` is true,
150
+ * consumed once by the next document's boot. Per-tab and cleared with the tab,
151
+ * which is the right lifetime for a marker about one navigation.
152
+ */
153
+ export const FALLBACK_MARKER_KEY = 'webjs:nav-fallback';
154
+
155
+ export const SNAPSHOT_CAP = 16;
156
+
157
+ /**
158
+ * Attribute names whose live DOM state must NEVER be overwritten by
159
+ * incoming server HTML during a partial swap. The server emits these
160
+ * with their initial-render value; the user may have typed/clicked
161
+ * between renders. Preserving them keeps focus, typing, open state,
162
+ * and popover state intact across navigation.
163
+ */
164
+ export const LIVE_ATTRS = new Set([
165
+ // Form controls
166
+ 'value', 'checked', 'selected', 'indeterminate', 'disabled',
167
+ // Disclosure / popover
168
+ 'open', 'popover',
169
+ ]);
170
+
171
+ /* ====================================================================
172
+ * Optimistic loading (per-segment loading.ts templates)
173
+ * ==================================================================== */
174
+
175
+ /**
176
+ * The one framework-owned keyed meta that must NEVER be reconciled: the CSP
177
+ * nonce. A soft-nav response carries a FRESH per-request nonce, but the browser
178
+ * enforces CSP against the nonce the ORIGINAL page load declared (see
179
+ * `getCspNonce`), so overwriting the live `csp-nonce` meta with the incoming
180
+ * one would make every later nonce-stamped script/preload violate the active
181
+ * policy. Excluded from add/update/remove so the original meta survives verbatim.
182
+ */
183
+ export const META_KEY_CSP_NONCE = 'name=csp-nonce';
184
+
185
+ /**
186
+ * Predicate used by the onClick handler to decide whether a same-origin
187
+ * href should bypass the router. Exposed for unit testing.
188
+ *
189
+ * @param {string} pathname
190
+ * @returns {boolean}
191
+ */
192
+ export function _isNonHtmlPath(pathname) {
193
+ return NON_HTML_EXTENSIONS.test(pathname);
194
+ }
195
+
196
+ /**
197
+ * How long the restore window's write-back stays armed (#1428).
198
+ *
199
+ * It exists to reconcile ONE event, the browser's replay of its own recorded
200
+ * scroll offset, which lands about a frame after the popstate handler. This is
201
+ * generous for that (roughly fifteen frames at 60Hz) and far short of the
202
+ * window's own life, so a legitimate programmatic scroll arriving later, a
203
+ * component's `scrollIntoView()` during upgrade or a find-in-page jump, is left
204
+ * alone rather than reverted.
205
+ */
206
+ export const WRITE_BACK_ARMED_MS = 250;