@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.
- package/dist/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +20 -20
- 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 +3 -3
- package/index.js +1 -1
- package/package.json +1 -1
- 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.js +2 -1896
- package/src/css.d.ts +9 -0
- package/src/escape.d.ts +2 -0
- package/src/html.d.ts +9 -0
- package/src/render-client/parts.js +2039 -0
- package/src/render-client/reconciler.js +388 -0
- package/src/render-client/template-compiler.js +525 -0
- package/src/render-client.js +1 -2938
- 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 +938 -0
- package/src/render-server/text.js +163 -0
- package/src/render-server.js +6 -2405
- package/src/repeat.d.ts +15 -0
- package/src/rich-fetch.d.ts +4 -0
- package/src/router-client/anchors.js +44 -0
- package/src/router-client/boundaries.js +221 -0
- package/src/router-client/constants.js +206 -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 +182 -0
- package/src/router-client/fetch-apply.js +432 -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 +952 -0
- package/src/router-client/prefetch.js +831 -0
- package/src/router-client/scroll.js +308 -0
- package/src/router-client/snapshot-cache.js +113 -0
- package/src/router-client/state.js +155 -0
- package/src/router-client/stream.js +206 -0
- package/src/router-client/swap.js +454 -0
- package/src/router-client/upgrade.js +261 -0
- package/src/router-client/view-transition.js +223 -0
- package/src/router-client.d.ts +11 -2
- package/src/router-client.js +78 -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/suspense.d.ts +9 -0
- 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; }
|