@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,308 @@
1
+ /**
2
+ * Client router: scroll.
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
+ import { ANCHOR_RELEASE_EVENTS, ANCHOR_SUPPRESS_CEILING_MS, WRITE_BACK_ARMED_MS } from './constants.js';
10
+
11
+ /**
12
+ * Closes the currently open restore window, or null when none is open.
13
+ * @type {(() => void) | null}
14
+ */
15
+ export let releaseScrollAnchor = null;
16
+
17
+ /**
18
+ * Suppress the browser's scroll anchoring for the duration of a back/forward
19
+ * scroll restore (#1310).
20
+ *
21
+ * A snapshot's `scrollY` is recorded against the page at its SETTLED height.
22
+ * The restore replays that number onto a document that has only just been
23
+ * swapped in and is still shorter, because the components in the restored
24
+ * markup have not upgraded and re-rendered yet. When they do, content grows
25
+ * ABOVE the viewport, and scroll anchoring (`overflow-anchor: auto`, the UA
26
+ * default) holds the VISUAL position by adding that growth to `scrollY`. The
27
+ * offset is counted twice. On webjs.dev's `/ui/button` that lands the reader
28
+ * 763px too low, exactly the settled-minus-swapped height delta.
29
+ *
30
+ * Anchoring is right for a reader on a live page and wrong for exactly this
31
+ * window, where the restored number already accounts for the growth. So the
32
+ * window suppresses it rather than re-scrolling afterwards. A re-assert would
33
+ * have to fire on every growth, and a settling restore cannot be told apart
34
+ * from a `<webjs-suspense>` boundary streaming in (#471 / #473). Suppression
35
+ * never MOVES the viewport, it only withholds a correction, so it also cannot
36
+ * yank a reader who has already started scrolling.
37
+ *
38
+ * Chromium, Firefox, and WebKit all implement scroll anchoring, and all three
39
+ * honour `overflow-anchor: none` identically whether it sits on the root
40
+ * scroller or on `<body>`, so there is no engine-specific path here.
41
+ *
42
+ * It goes on the ROOT, and `<body>` is not an alternative even though it looks
43
+ * like the tidier one. Suppressing on `<body>` works identically on all three
44
+ * engines (the property excludes an element and its subtree from being chosen
45
+ * as the anchor, and every candidate lives under `<body>`), and it would avoid
46
+ * writing to the root at all, which is worth wanting: toggling something on the
47
+ * root re-runs global style resolution, and on WebKit that re-resolves
48
+ * `oklch()` token values and repaints them for a frame, which is the #610 flash
49
+ * that made `data-navigating` opt-in.
50
+ *
51
+ * It is disqualified by the RELEASE, not the suppression. On WebKit, anchoring
52
+ * never resumes once it has been suppressed on `<body>`: removing the property,
53
+ * setting it back to `auto`, and both in sequence were each measured, and after
54
+ * every one the next growth above the viewport still failed to move `scrollY`.
55
+ * Suppressing on the root resumes correctly on all three. Since the whole point
56
+ * is that suppression is TEMPORARY, a placement that cannot be undone would
57
+ * leave every WebKit reader, so every iOS browser, with scroll anchoring off
58
+ * for the life of the page after their first Back. That is a far worse trade
59
+ * than one repaint, so the root it is.
60
+ *
61
+ * @param {number} [targetX] the restore's recorded horizontal offset. Together
62
+ * with `targetY` this arms the write-back below; omit BOTH to open a
63
+ * suppression-only window with no scroll enforcement.
64
+ * @param {number} [targetY] the restore's recorded vertical offset. The
65
+ * write-back is armed only when this is a number, so a caller that passes
66
+ * nothing gets the pre-#1428 behaviour.
67
+ * @returns {() => void} Idempotent release. Safe to call after the window has
68
+ * already closed on user input or the ceiling.
69
+ */
70
+ export function suppressScrollAnchoring(targetX, targetY) {
71
+ if (typeof document === 'undefined' || !document.documentElement) return () => {};
72
+ // A second restore inside an open window supersedes the first.
73
+ if (releaseScrollAnchor) releaseScrollAnchor();
74
+ const root = document.documentElement;
75
+ // Save and restore the author's own inline value rather than blanking it,
76
+ // the same save-and-put-back contract the router keeps elsewhere.
77
+ const prev = root.style.getPropertyValue('overflow-anchor');
78
+ root.style.setProperty('overflow-anchor', 'none');
79
+ /** @type {ReturnType<typeof setTimeout> | null} */
80
+ let timer = null;
81
+ // With a target, the window also WRITES BACK a programmatic displacement
82
+ // (#1428). The browser owns the restore now, and its replay is the offset IT
83
+ // recorded for the entry, which can DISAGREE with the snapshot: an entry the
84
+ // router snapshotted without the reader having scrolled there under the UA's
85
+ // observation carries a stale 0. This write-back is what reconciles the two,
86
+ // and it is the only thing that does, so it is not redundant with the UA
87
+ // being correct in the common case. A user can never trigger
88
+ // this listener first: every release event is a CAPTURE-phase input
89
+ // listener (wheel / touchmove / keydown / pointerdown), and each of those
90
+ // fires BEFORE the scroll event that follows it, so by the time a
91
+ // user-driven scroll lands here the window is already closed. What remains
92
+ // is exactly a programmatic non-user write inside the restore's own span,
93
+ // which is the one thing the restore may overrule. Re-entry is benign: the
94
+ // write-back's own scroll event arrives on-target and returns.
95
+ //
96
+ // It is armed only BRIEFLY, not for the window's full life. The thing it
97
+ // exists to reconcile is one event, the UA's replay of its own recorded
98
+ // offset, which lands about a frame after the popstate handler. Left armed
99
+ // for the window's life (the revalidation settle, or up to the 2s ceiling)
100
+ // it would spend seconds reverting any programmatic scroll, and the longer it
101
+ // stands the more it claims. 250ms bounds that.
102
+ //
103
+ // It does NOT make the write-back harmless, and the honest statement is that
104
+ // a programmatic scroll inside the arming span still loses to the restore. A
105
+ // component calling `scrollIntoView()` from `connectedCallback` as the
106
+ // restored page upgrades, or an autofocus on a below-fold control, runs a
107
+ // microtask or a frame after the swap, which is INSIDE 250ms, so it is
108
+ // reverted. That is the deliberate precedence on a Back: the reader asked for
109
+ // the page they left, not for wherever a component jumped while rehydrating.
110
+ // What the bound buys is that a scroll a second later, which has nothing to
111
+ // do with the restore, is left alone; find-in-page from the browser chrome is
112
+ // the case that matters there, since it fires no page `keydown` and so does
113
+ // not close the window the way a real key press would.
114
+ //
115
+ // Bounded by TIME rather than by a correction count. A count is the obvious
116
+ // bound and it is wrong: a single-shot can be spent on an unrelated scroll
117
+ // event that happens to arrive before the UA's replay, after which the stale
118
+ // replay stands uncorrected. That reproduced on Firefox as an intermittent,
119
+ // roughly one run in three. A short arming span covers the replay whenever it
120
+ // lands, however many events precede it.
121
+ let armed = typeof targetY === 'number';
122
+ const disarm = () => { armed = false; };
123
+ const armTimer = armed && typeof setTimeout === 'function'
124
+ ? setTimeout(disarm, WRITE_BACK_ARMED_MS) : null;
125
+ const onScroll = (typeof targetY === 'number' && typeof window !== 'undefined')
126
+ ? () => {
127
+ if (!armed) return;
128
+ if (Math.abs(window.scrollY - targetY) <= 1 && Math.abs(window.scrollX - (targetX || 0)) <= 1) return;
129
+ window.scrollTo({ left: targetX || 0, top: targetY, behavior: 'instant' });
130
+ }
131
+ : null;
132
+ const release = () => {
133
+ // Only the window that installed this release may close it.
134
+ if (releaseScrollAnchor !== release) return;
135
+ releaseScrollAnchor = null;
136
+ if (timer) { clearTimeout(timer); timer = null; }
137
+ if (armTimer) clearTimeout(armTimer);
138
+ armed = false;
139
+ if (typeof window !== 'undefined') {
140
+ for (const ev of ANCHOR_RELEASE_EVENTS) {
141
+ window.removeEventListener(ev, release, /** @type {any} */ ({ capture: true }));
142
+ }
143
+ if (onScroll) window.removeEventListener('scroll', onScroll);
144
+ }
145
+ if (prev) root.style.setProperty('overflow-anchor', prev);
146
+ else root.style.removeProperty('overflow-anchor');
147
+ };
148
+ releaseScrollAnchor = release;
149
+ timer = setTimeout(release, ANCHOR_SUPPRESS_CEILING_MS);
150
+ if (typeof window !== 'undefined') {
151
+ for (const ev of ANCHOR_RELEASE_EVENTS) {
152
+ window.addEventListener(ev, release, { capture: true, passive: true });
153
+ }
154
+ if (onScroll) window.addEventListener('scroll', onScroll, { passive: true });
155
+ }
156
+ return release;
157
+ }
158
+
159
+ /**
160
+ * Closes the currently held height reservation, or null when none is open.
161
+ * @type {(() => void) | null}
162
+ */
163
+ export let releaseHeightReservation = null;
164
+
165
+ /**
166
+ * Reserve the restored page's SETTLED height across a Back/Forward restore
167
+ * (#1428 architecture).
168
+ *
169
+ * A restore re-inserts an outerHTML snapshot, and that markup is SHORTER than
170
+ * the page it was serialized from until its components upgrade and re-render a
171
+ * beat later. Every scroll defect in this file's history lived in that window:
172
+ * the browser clamped the recorded offset against the short document (#1310's
173
+ * clamped band, formerly healed by a chase), and the UA's own restoration
174
+ * landed short the same way. Holding the settled height on the ROOT element
175
+ * makes the recorded offset reachable from the first frame, so a restore lands
176
+ * exactly, once, and the clamp class cannot occur at all.
177
+ *
178
+ * The root and not `<body>`, because the restore REPLACES the whole body, so
179
+ * an inline style there would leave with the old node. Same
180
+ * save-and-put-back contract as `overflow-anchor` above.
181
+ *
182
+ * Released on the restore's settle, on the ceiling, and on supersede (a new
183
+ * page navigation, a submission, disabling the router). NEVER on user input:
184
+ * the window releases early for a reader taking over, but yanking the page's
185
+ * height out from under a reader mid-scroll is the one harm an early release
186
+ * here could cause, so this deliberately does not share that trigger.
187
+ *
188
+ * @param {number} px the snapshot's recorded `scrollHeight`
189
+ * @returns {() => void} Idempotent release.
190
+ */
191
+ export function reserveRestoredHeight(px) {
192
+ if (typeof document === 'undefined' || !document.documentElement) return () => {};
193
+ // A second reservation supersedes the first, and does so BEFORE the height
194
+ // guard below. Ordering it after would let a restore with no recorded height
195
+ // (a legacy string snapshot, `scrollHeight: 0`) return early while the
196
+ // PREVIOUS page's min-height stayed pinned to the root until the ceiling.
197
+ // Superseding first means every restore clears its predecessor, whether or
198
+ // not it has a height of its own to hold.
199
+ if (releaseHeightReservation) releaseHeightReservation();
200
+ if (!(px > 0)) return () => {};
201
+ const root = document.documentElement;
202
+ const prev = root.style.getPropertyValue('min-height');
203
+ root.style.setProperty('min-height', px + 'px');
204
+ /** @type {ReturnType<typeof setTimeout> | null} */
205
+ let timer = null;
206
+ const release = () => {
207
+ if (releaseHeightReservation !== release) return;
208
+ releaseHeightReservation = null;
209
+ if (timer) { clearTimeout(timer); timer = null; }
210
+ if (prev) root.style.setProperty('min-height', prev);
211
+ else root.style.removeProperty('min-height');
212
+ };
213
+ releaseHeightReservation = release;
214
+ timer = setTimeout(release, ANCHOR_SUPPRESS_CEILING_MS);
215
+ return release;
216
+ }
217
+
218
+ /**
219
+ * Bumped wherever a restore is superseded (#1310), and read by the one restore
220
+ * path that outlives the call scheduling it. That means a PAGE navigation, a
221
+ * PAGE-level submission, and disabling the router. A frame-targeted nav or
222
+ * submission swaps one region and leaves the page, so it is excluded, exactly
223
+ * like the `loadFrame` case.
224
+ *
225
+ * Deliberately NOT `currentNavigationToken`, which is the obvious choice and the
226
+ * wrong one: `loadFrame` bumps that too, and its own contract says a frame
227
+ * self-load is not a page navigation. An eager `<webjs-frame src>` inside a
228
+ * RESTORED snapshot loads during the swap, so keying on the nav token would
229
+ * read a routine frame load as a supersede and drop the entire restore, leaving
230
+ * the reader at the outgoing page's offset. That is worse than the defect this
231
+ * whole change fixes. This counter moves only for the three things that really
232
+ * do end a restore.
233
+ */
234
+ export let restoreGeneration = 0;
235
+
236
+ /**
237
+ * Run `fn` after two animation frames, so a just-applied DOM has laid out
238
+ * before it reads or acts. Falls back to a macrotask where
239
+ * `requestAnimationFrame` is absent (the linkedom-backed node test harness).
240
+ *
241
+ * @param {() => void} fn
242
+ */
243
+ export function afterTwoFrames(fn) {
244
+ if (typeof requestAnimationFrame !== 'function') { setTimeout(fn, 0); return; }
245
+ requestAnimationFrame(() => requestAnimationFrame(fn));
246
+ }
247
+
248
+ /**
249
+ * Bump the restore generation.
250
+ *
251
+ * `restoreGeneration` is written by this module AND by the navigator, where
252
+ * every fresh navigation invalidates an in-flight scroll restore. An ESM import
253
+ * binding cannot be assigned across a module boundary, so the navigator calls
254
+ * this rather than doing `restoreGeneration += 1` itself. Same statement, same
255
+ * semantics, one indirection.
256
+ */
257
+ export function bumpRestoreGeneration() {
258
+ restoreGeneration += 1;
259
+ }
260
+
261
+ /**
262
+ * Whether this navigation should keep the reader's current scroll offset rather
263
+ * than scrolling to top (#1436).
264
+ *
265
+ * Resolved from `data-preserve-scroll` on the trigger OR the nearest ancestor
266
+ * carrying it, so one filter bar / tab strip / breadcrumb marks every link in it
267
+ * at once. That is `resolveTargetFrameId`'s precedent (`frames.js`), not
268
+ * `data-no-router`'s element-only read: `data-no-router` turns the router OFF
269
+ * for a link, a big enough hammer that an ancestor doing it silently would
270
+ * surprise, while this is a soft preference whose natural authoring unit is a
271
+ * region.
272
+ *
273
+ * VALUE-aware, and the only value that means anything is the literal `false`.
274
+ * That is Remix 3's `rmx-reset-scroll` test (`!== 'false'`) and the value
275
+ * vocabulary `prefetchMode` already accepts here. Because `closest()` returns
276
+ * the NEAREST carrier, `data-preserve-scroll="false"` on one link inside a
277
+ * marked wrapper opts that link back into the default with no extra logic.
278
+ *
279
+ * `fallback` is consulted only when `trigger` resolves NO carrier, which is what
280
+ * a form submission needs. A submitter is form-associated by `form="id"` rather
281
+ * than by containment, so `<button form="f">` may sit anywhere in the document
282
+ * and `closest()` from it would never pass through the form it submits. Falling
283
+ * back to the form covers that, and ordering it AFTER the trigger is what keeps
284
+ * `data-preserve-scroll="false"` on the submitter winning over a marked form:
285
+ * a resolved carrier is never overridden, only a missing one is filled in.
286
+ *
287
+ * @param {Element | null} trigger the clicked anchor, or a submitted form's
288
+ * submitter (falling back to the form when there is no submitter).
289
+ * @param {Element | null} [fallback] the submitted form, consulted only when
290
+ * the trigger carries no mark and is not inside one.
291
+ * @returns {boolean}
292
+ */
293
+ export function resolvePreserveScroll(trigger, fallback) {
294
+ const carrier = findScrollCarrier(trigger) || findScrollCarrier(fallback);
295
+ if (!carrier) return false;
296
+ return (carrier.getAttribute('data-preserve-scroll') || '').toLowerCase().trim() !== 'false';
297
+ }
298
+
299
+ /**
300
+ * The nearest element at or above `el` carrying `data-preserve-scroll`.
301
+ *
302
+ * @param {Element | null | undefined} el
303
+ * @returns {Element | null}
304
+ */
305
+ function findScrollCarrier(el) {
306
+ if (!el || typeof el.closest !== 'function') return null;
307
+ return el.closest('[data-preserve-scroll]');
308
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Client router: snapshot-cache.
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
+ import { SNAPSHOT_CAP } from './constants.js';
10
+
11
+ /** @typedef {{ html: string, scrollX: number, scrollY: number, scrollHeight: number }} Snapshot */
12
+ /** @type {Map<string, Snapshot | string>} */
13
+ export const snapshotCache = new Map();
14
+
15
+ /**
16
+ * Cache the current document's HTML + window scroll position keyed by
17
+ * URL. Used on back/forward navigation: the cached DOM restores
18
+ * instantly, scroll position restores to whatever the user left it at.
19
+ *
20
+ * Turbo Drive captures `window.pageXOffset/pageYOffset` on every scroll
21
+ * event into history state. Webjs captures lazily at snapshot time -
22
+ * one read per nav rather than one per scroll event. Sufficient because
23
+ * we only need the position at the moment of leaving.
24
+ *
25
+ * @param {string} url
26
+ */
27
+ export function snapshotCurrent(url) {
28
+ const key = cacheKey(url);
29
+ // Let components and app code strip transient state (open overlays, toasts,
30
+ // in-progress wizard steps) from the page BEFORE it is serialized into the
31
+ // back/forward cache, so a later popstate restore shows a clean page rather
32
+ // than, say, a hover-card frozen open (#766, Turbo's `before-cache` contract).
33
+ // Fires SYNCHRONOUSLY on the live DOM right before the outerHTML read, so a
34
+ // handler's mutations are captured; the live edits are invisible because the
35
+ // page is being navigated away from.
36
+ document.dispatchEvent(new CustomEvent('webjs:before-cache', { detail: { url } }));
37
+ // Move-to-front for LRU.
38
+ if (snapshotCache.has(key)) snapshotCache.delete(key);
39
+ /** @type {Snapshot} */
40
+ const snap = {
41
+ html: document.documentElement.outerHTML,
42
+ scrollX: typeof window !== 'undefined' ? window.scrollX || 0 : 0,
43
+ scrollY: typeof window !== 'undefined' ? window.scrollY || 0 : 0,
44
+ // The page's SETTLED height, captured at the same moment as the offset.
45
+ // A restore re-inserts this snapshot as raw markup, and the document is
46
+ // SHORTER than this until its components upgrade and re-render, which is
47
+ // the window every scroll defect in #1310 lived in. The restore reserves
48
+ // this height across that window (#1428 architecture), so the recorded
49
+ // offset is reachable from the first frame and the browser's own
50
+ // restoration lands exactly, with no clamp and nothing to chase.
51
+ scrollHeight: typeof document !== 'undefined' && document.documentElement
52
+ ? document.documentElement.scrollHeight || 0 : 0,
53
+ };
54
+ snapshotCache.set(key, snap);
55
+ while (snapshotCache.size > SNAPSHOT_CAP) {
56
+ const oldest = snapshotCache.keys().next().value;
57
+ snapshotCache.delete(oldest);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Look up a cached snapshot by URL. Returns a normalized Snapshot or
63
+ * null. Tolerates legacy string entries (e.g. from test fixtures that
64
+ * `_snapshotCache.set('/x', 'snap')`).
65
+ *
66
+ * @param {string} url
67
+ * @returns {Snapshot | null}
68
+ */
69
+ export function snapshotGet(url) {
70
+ const key = cacheKey(url);
71
+ const v = snapshotCache.get(key);
72
+ if (v == null) return null;
73
+ // Move-to-front.
74
+ snapshotCache.delete(key);
75
+ snapshotCache.set(key, v);
76
+ // A legacy string entry carries no offsets and no height. `scrollHeight: 0`
77
+ // is stated rather than left undefined so the returned object satisfies the
78
+ // `Snapshot` typedef, and because `reserveRestoredHeight` treats a
79
+ // non-positive height as "nothing to reserve" and no-ops: there is no
80
+ // recorded height to hold, so reserving is not merely skippable, it is
81
+ // meaningless. Such an entry restores with no reservation, which is the
82
+ // pre-#1428 behaviour and correct for a snapshot that never recorded one.
83
+ if (typeof v === 'string') return { html: v, scrollX: 0, scrollY: 0, scrollHeight: 0 };
84
+ return v;
85
+ }
86
+
87
+ /**
88
+ * The cache key for a URL, optionally in a `<webjs-frame>` dimension (#1407).
89
+ *
90
+ * A frame response is SLICED by the `x-webjs-frame` request header and the
91
+ * server marks it `Vary: X-Webjs-Frame`, so a client-side cache of that
92
+ * response has to carry the same dimension or a frame subtree and a page
93
+ * fragment for one URL alias onto each other.
94
+ *
95
+ * The delimiter is a single SPACE, which cannot occur in either half: the URL
96
+ * parser percent-encodes U+0020 in both the path and the query, and an HTML
97
+ * `id` may not contain ASCII whitespace. So a framed key always holds a space
98
+ * and an unframed one never does, and even a non-conforming id set through the
99
+ * DOM stays unambiguous, because the space-free URL half means the LAST space
100
+ * separates the two.
101
+ *
102
+ * @param {string} url
103
+ * @param {string | null} [frameId]
104
+ */
105
+ export function cacheKey(url, frameId) {
106
+ const u = new URL(url, location.href);
107
+ const path = u.pathname + u.search;
108
+ return frameId ? `${frameId} ${path}` : path;
109
+ }
110
+
111
+ /* ====================================================================
112
+ * Navigation
113
+ * ==================================================================== */
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Client router: state.
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
+ * Monotonic counter incremented at the start of every navigation. Each
12
+ * async path captures the value at its entry point and compares before
13
+ * applying side effects (swap, restore-optimistic). A mismatch means a
14
+ * newer nav superseded this one: bail out silently. Belt-and-suspenders
15
+ * on top of AbortController: covers paths where a response has already
16
+ * resolved past the await but a newer nav started before applySwap ran.
17
+ */
18
+ export let currentNavigationToken = 0;
19
+
20
+ /**
21
+ * The one place the router hands a navigation back to the browser.
22
+ *
23
+ * Every hard navigation the router performs goes through here rather than
24
+ * assigning `location.href` inline, so a browser test can observe it. The
25
+ * default is exactly the assignment it replaces, so behaviour is unchanged
26
+ * unless something calls `setHardNavigate`.
27
+ *
28
+ * This exists because a hard navigation is UNOBSERVABLE and UNPREVENTABLE from
29
+ * outside. `preventDefault` cancels a default action, not a script assignment,
30
+ * and `location.href` is non-configurable on Chromium, Firefox, and WebKit
31
+ * alike, so a test cannot redefine its setter either (measured; the older
32
+ * `spyOnReload` helper that tried was silently a no-op on every engine). In a
33
+ * web-test-runner session a real navigation aborts the WHOLE session, so one
34
+ * degradation destroys every remaining browser test file and reports `0 failed`
35
+ * on the way out. A seam is the only thing that makes it catchable.
36
+ *
37
+ * @param {string} href
38
+ */
39
+ export let hardNavigate = (href) => { location.href = href; };
40
+
41
+ /**
42
+ * Test-only: replace the hard-navigate action so a browser test can observe a
43
+ * navigation instead of being destroyed by it. Call with no argument to
44
+ * restore. Underscore-prefixed and kept in this block like every other
45
+ * test-only export here, so it stays out of `router-client.d.ts` and out of
46
+ * the app-facing API (the `./client-router` subpath resolves this file under
47
+ * the `source` condition, so an unprefixed name here would read as public).
48
+ *
49
+ * @param {((href: string) => void) | null} [fn]
50
+ */
51
+ export function _setHardNavigate(fn) {
52
+ hardNavigate = fn || ((href) => { location.href = href; });
53
+ }
54
+
55
+ /** Test-only: read the monotonic navigation-token counter. */
56
+ export function _navToken() { return currentNavigationToken; }
57
+
58
+ /** Test-only: bump the navigation-token counter (simulates a fresh nav). */
59
+ export function _bumpNavToken() { return ++currentNavigationToken; }
60
+
61
+ /**
62
+ * Bump the navigation token and return the new value.
63
+ *
64
+ * The navigator opens every navigation with `++currentNavigationToken`, and the
65
+ * counter lives here beside the `_navToken` seam that reads it. An ESM import
66
+ * binding cannot be incremented from another module, so the navigator calls
67
+ * this and keeps the same capture-then-compare pattern.
68
+ */
69
+ export function bumpNavToken() {
70
+ return ++currentNavigationToken;
71
+ }
72
+
73
+ /**
74
+ * Whether the client router is currently intercepting navigations.
75
+ *
76
+ * Owned here rather than in navigator.js because `events.js` and
77
+ * `prefetch.js` both gate on it, so parking it beside the code that flips it
78
+ * made two leaf-ward modules import the orchestrator and pulled them into the
79
+ * router's import cycle. navigator.js still owns the TRANSITIONS through
80
+ * `_setEnabled`; this module just holds the bit.
81
+ */
82
+ export let enabled = false;
83
+
84
+ /**
85
+ * Flip the router-enabled bit. Called only by enableClientRouter /
86
+ * disableClientRouter in navigator.js.
87
+ *
88
+ * @param {boolean} v
89
+ */
90
+ export function _setEnabled(v) {
91
+ enabled = v;
92
+ }
93
+
94
+ /**
95
+ * The href a bowed-out same-document fragment CLICK is navigating to, pending
96
+ * the popstate that click is about to produce.
97
+ *
98
+ * This exists because URLs alone cannot separate the two ways a popstate can
99
+ * arrive carrying the url the reader is already on, and the two need opposite
100
+ * treatment (#1437).
101
+ *
102
+ * - A REPEAT click of one in-page anchor REPLACES its history entry rather
103
+ * than pushing, so it fires popstate with `location.href` unchanged. The
104
+ * browser has already done the jump and there is nothing to fetch.
105
+ * - A Back between two DISTINCT entries that happen to share a url is a real
106
+ * traversal that must re-render. The no-JS write path produces that pair: a
107
+ * bound `<form action=${fn}>` emits no `action` attribute (invariant 12), so
108
+ * `form.action` reflects the node document's URL and the 422 re-render
109
+ * pushes a duplicate entry at it.
110
+ *
111
+ * An earlier attempt tried to tell them apart by whether the url carried a
112
+ * fragment, on the reasoning that a repeat anchor click always has one and a
113
+ * 422 entry never does. The second half is false: `form.action` returns the
114
+ * document URL WITH its fragment (measured in Chromium, for a missing `action`
115
+ * attribute and an empty one alike), so a reader who used an in-page anchor
116
+ * before submitting produces a 422 entry carrying `#sec`, and the Back out of
117
+ * the validation error was swallowed.
118
+ *
119
+ * So the signal is not the url, it is provenance: the router SAW the click it
120
+ * bowed out of, and it never sees a traversal. `onClick` records the href here
121
+ * on its way out, the very next popstate consumes it, and anything that starts
122
+ * a real navigation or submission drops it so it cannot leak across.
123
+ *
124
+ * Underscore-prefixed because no source module reads the binding (they go
125
+ * through the three functions below); its only reader is the test suite, and
126
+ * this file's own convention is that an unprefixed export here reads as public
127
+ * API.
128
+ *
129
+ * @type {string | null}
130
+ */
131
+ export let _pendingFragmentNav = null;
132
+
133
+ /**
134
+ * Record that a bowed-out fragment click is about to fire a popstate.
135
+ *
136
+ * @param {string} href The absolute href the click is navigating to.
137
+ */
138
+ export function markFragmentNav(href) { _pendingFragmentNav = href; }
139
+
140
+ /**
141
+ * Consume the pending mark if it matches, and clear it either way. Clearing on
142
+ * a MISS matters as much as on a hit: a mark that outlived its popstate must
143
+ * not sit around waiting to swallow an unrelated one.
144
+ *
145
+ * @param {string} href `location.href` at popstate time.
146
+ * @returns {boolean} True when this popstate is the one that click produced.
147
+ */
148
+ export function consumeFragmentNav(href) {
149
+ const hit = _pendingFragmentNav !== null && _pendingFragmentNav === href;
150
+ _pendingFragmentNav = null;
151
+ return hit;
152
+ }
153
+
154
+ /** Drop the pending mark. Any real navigation or submission invalidates it. */
155
+ export function clearFragmentNav() { _pendingFragmentNav = null; }