@webjsdev/core 0.7.49 → 0.7.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +21 -21
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -7
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +1 -1
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/attribute-reader.js +178 -0
- package/src/component/lifecycle.js +1481 -0
- package/src/component/reactive.js +171 -0
- package/src/component/server-element.js +248 -0
- package/src/component.d.ts +10 -0
- package/src/component.js +2 -1931
- package/src/html-entities.js +2179 -0
- package/src/render-client/parts.js +2007 -0
- package/src/render-client/reconciler.js +388 -0
- package/src/render-client/template-compiler.js +525 -0
- package/src/render-client.js +1 -2939
- package/src/render-server/attrs.js +160 -0
- package/src/render-server/dsd.js +445 -0
- package/src/render-server/html-scan.js +402 -0
- package/src/render-server/slots.js +213 -0
- package/src/render-server/stream.js +163 -0
- package/src/render-server/template-renderer.js +923 -0
- package/src/render-server/text.js +163 -0
- package/src/render-server.js +6 -2334
- package/src/router-client/anchors.js +44 -0
- package/src/router-client/boundaries.js +221 -0
- package/src/router-client/constants.js +187 -0
- package/src/router-client/diagnostics.js +377 -0
- package/src/router-client/dom-differ.js +709 -0
- package/src/router-client/dom-parse.js +150 -0
- package/src/router-client/events.js +139 -0
- package/src/router-client/fetch-apply.js +387 -0
- package/src/router-client/form-encoder.js +135 -0
- package/src/router-client/frames.js +329 -0
- package/src/router-client/head-merge.js +344 -0
- package/src/router-client/nav-error.js +105 -0
- package/src/router-client/navigator.js +781 -0
- package/src/router-client/prefetch.js +824 -0
- package/src/router-client/scroll.js +259 -0
- package/src/router-client/snapshot-cache.js +97 -0
- package/src/router-client/state.js +92 -0
- package/src/router-client/stream.js +206 -0
- package/src/router-client/swap.js +451 -0
- package/src/router-client/upgrade.js +261 -0
- package/src/router-client/view-transition.js +223 -0
- package/src/router-client.d.ts +10 -1
- package/src/router-client.js +75 -5345
- package/src/slot/assignment.js +304 -0
- package/src/slot/interception.js +558 -0
- package/src/slot/polyfills.js +365 -0
- package/src/slot/project.js +432 -0
- package/src/slot/sensors.js +303 -0
- package/src/slot/state.js +245 -0
- package/src/slot/symbols.js +85 -0
- package/src/slot.js +7 -2199
- package/src/webjs-config.d.ts +13 -2
|
@@ -0,0 +1,259 @@
|
|
|
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, ANCHOR_SUPPRESS_FLOOR_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
|
+
* @returns {() => void} Idempotent release. Safe to call after the window has
|
|
62
|
+
* already closed on user input or the ceiling.
|
|
63
|
+
*/
|
|
64
|
+
export function suppressScrollAnchoring() {
|
|
65
|
+
if (typeof document === 'undefined' || !document.documentElement) return () => {};
|
|
66
|
+
// A second restore inside an open window supersedes the first.
|
|
67
|
+
if (releaseScrollAnchor) releaseScrollAnchor();
|
|
68
|
+
const root = document.documentElement;
|
|
69
|
+
// Save and restore the author's own inline value rather than blanking it,
|
|
70
|
+
// the same contract `prevScrollRestoration` keeps above.
|
|
71
|
+
const prev = root.style.getPropertyValue('overflow-anchor');
|
|
72
|
+
root.style.setProperty('overflow-anchor', 'none');
|
|
73
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
74
|
+
let timer = null;
|
|
75
|
+
const release = () => {
|
|
76
|
+
// Only the window that installed this release may close it.
|
|
77
|
+
if (releaseScrollAnchor !== release) return;
|
|
78
|
+
releaseScrollAnchor = null;
|
|
79
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
80
|
+
if (typeof window !== 'undefined') {
|
|
81
|
+
for (const ev of ANCHOR_RELEASE_EVENTS) {
|
|
82
|
+
window.removeEventListener(ev, release, /** @type {any} */ ({ capture: true }));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (prev) root.style.setProperty('overflow-anchor', prev);
|
|
86
|
+
else root.style.removeProperty('overflow-anchor');
|
|
87
|
+
};
|
|
88
|
+
releaseScrollAnchor = release;
|
|
89
|
+
timer = setTimeout(release, ANCHOR_SUPPRESS_CEILING_MS);
|
|
90
|
+
if (typeof window !== 'undefined') {
|
|
91
|
+
for (const ev of ANCHOR_RELEASE_EVENTS) {
|
|
92
|
+
window.addEventListener(ev, release, { capture: true, passive: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return release;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Cancels an in-flight catch-up, or null when none is running.
|
|
100
|
+
* @type {(() => void) | null}
|
|
101
|
+
*/
|
|
102
|
+
export let cancelScrollCatchUp = null;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Bumped wherever a restore is superseded (#1310), and read by the one restore
|
|
106
|
+
* path that outlives the call scheduling it. That means a PAGE navigation, a
|
|
107
|
+
* PAGE-level submission, and disabling the router. A frame-targeted nav or
|
|
108
|
+
* submission swaps one region and leaves the page, so it is excluded, exactly
|
|
109
|
+
* like the `loadFrame` case below.
|
|
110
|
+
*
|
|
111
|
+
* Deliberately NOT `currentNavigationToken`, which is the obvious choice and the
|
|
112
|
+
* wrong one: `loadFrame` bumps that too, and its own contract says a frame
|
|
113
|
+
* self-load is not a page navigation. An eager `<webjs-frame src>` inside a
|
|
114
|
+
* RESTORED snapshot loads during the swap, so keying on the nav token would
|
|
115
|
+
* read a routine frame load as a supersede and drop the entire restore, leaving
|
|
116
|
+
* the reader at the outgoing page's offset. That is worse than the defect this
|
|
117
|
+
* whole change fixes. This counter moves only for the three things that really
|
|
118
|
+
* do end a restore.
|
|
119
|
+
*/
|
|
120
|
+
export let restoreGeneration = 0;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Chase a restored scroll offset the document was too SHORT to reach (#1310).
|
|
124
|
+
*
|
|
125
|
+
* The sibling of `suppressScrollAnchoring`, for the case that one deliberately
|
|
126
|
+
* declines. When the recorded offset is past the un-grown document's maximum,
|
|
127
|
+
* the browser clamps, and anchoring then adds the growth back as the page
|
|
128
|
+
* settles. That lands a reader who left at the very bottom back at the bottom,
|
|
129
|
+
* because there the shortfall and the growth are the same number. It is wrong
|
|
130
|
+
* for everyone else: anchoring adds the FULL growth whatever the shortfall was,
|
|
131
|
+
* so a reader who left 100px above the bottom is carried 100px too far.
|
|
132
|
+
*
|
|
133
|
+
* This re-asserts the recorded offset once the document can actually hold it,
|
|
134
|
+
* which is the only moment the number becomes reachable, and then stops.
|
|
135
|
+
*
|
|
136
|
+
* It is deliberately narrow, because #1310 rejected re-asserting the scroll in
|
|
137
|
+
* the general case and that reasoning still holds. The difference is that this
|
|
138
|
+
* knows exactly where it is going and can tell when it has arrived: it runs
|
|
139
|
+
* ONLY on the clamped path, only while the offset is still out of reach, writes
|
|
140
|
+
* once, and stops on the first real input.
|
|
141
|
+
*
|
|
142
|
+
* It does NOT escape the settling-versus-streaming question, and it is worth
|
|
143
|
+
* being exact about that rather than claiming otherwise. It cannot tell the
|
|
144
|
+
* restore settling apart from any other growth, so the guard is its WINDOW: it
|
|
145
|
+
* lives for `ANCHOR_SUPPRESS_FLOOR_MS` from the RESTORE and no longer. That is
|
|
146
|
+
* tighter than the window a landed restore gets, which runs to the later of the
|
|
147
|
+
* floor and the revalidation and is capped by the ceiling, and deliberately so,
|
|
148
|
+
* because this path WRITES scroll. The suppression this path installs once the
|
|
149
|
+
* chase lands is part of the same window, not a second one: it shares this
|
|
150
|
+
* deadline, so the whole clamped path is bounded by one floor measured from the
|
|
151
|
+
* restore however late the landing happens.
|
|
152
|
+
*
|
|
153
|
+
* Be precise about what the bound does and does not buy, since it is easy to
|
|
154
|
+
* overclaim in both directions. WHILE the window is open the reader is
|
|
155
|
+
* protected: until the offset is reachable there is nothing to protect, and
|
|
156
|
+
* from the moment the chase lands on it, suppression holds it against the rest
|
|
157
|
+
* of the growth. AFTER the window closes, both halves stop: the router writes
|
|
158
|
+
* no more scroll, and anchoring is back on, so any growth still arriving is
|
|
159
|
+
* added to `scrollY` and carries the reader down toward the bottom, which is
|
|
160
|
+
* main's behaviour. So the cost of a component that settles later than the
|
|
161
|
+
* window is that the reader drifts below the offset, not that they sit at the
|
|
162
|
+
* clamp.
|
|
163
|
+
*
|
|
164
|
+
* @param {number} targetY The recorded offset to reach.
|
|
165
|
+
* @param {number} targetX
|
|
166
|
+
*/
|
|
167
|
+
export function catchUpToRestoredScroll(targetY, targetX) {
|
|
168
|
+
if (typeof window === 'undefined' || typeof requestAnimationFrame !== 'function') return;
|
|
169
|
+
if (cancelScrollCatchUp) cancelScrollCatchUp();
|
|
170
|
+
let rafId = 0;
|
|
171
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
172
|
+
let timer = null;
|
|
173
|
+
/** Release for the suppression installed once the offset is reached. */
|
|
174
|
+
let releaseLanded = null;
|
|
175
|
+
const stop = () => {
|
|
176
|
+
if (cancelScrollCatchUp !== stop) return;
|
|
177
|
+
cancelScrollCatchUp = null;
|
|
178
|
+
if (rafId) cancelAnimationFrame(rafId);
|
|
179
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
180
|
+
if (releaseLanded) { releaseLanded(); releaseLanded = null; }
|
|
181
|
+
for (const ev of ANCHOR_RELEASE_EVENTS) {
|
|
182
|
+
window.removeEventListener(ev, stop, /** @type {any} */ ({ capture: true }));
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
const tick = () => {
|
|
186
|
+
if (cancelScrollCatchUp !== stop) return;
|
|
187
|
+
const maxY = document.documentElement.scrollHeight - window.innerHeight;
|
|
188
|
+
if (maxY >= targetY) {
|
|
189
|
+
// Reachable at last. Land the reader on the recorded offset.
|
|
190
|
+
window.scrollTo({ left: targetX, top: targetY, behavior: 'instant' });
|
|
191
|
+
// And then protect it, because landing is not the end of the story. The
|
|
192
|
+
// growth that made the offset reachable is rarely all of it: the real
|
|
193
|
+
// cause is components upgrading one at a time, so more arrives after
|
|
194
|
+
// this. Anchoring is still on here, deliberately, so every later stage
|
|
195
|
+
// would be added on top of the offset just written and carry the reader
|
|
196
|
+
// below it again. Measured on a two-stage fixture, an offset of 4000
|
|
197
|
+
// ended at 5000.
|
|
198
|
+
//
|
|
199
|
+
// Once the reader IS on the recorded offset the situation is identical to
|
|
200
|
+
// a restore that landed on its first try, so it gets that case's
|
|
201
|
+
// protection for what remains.
|
|
202
|
+
//
|
|
203
|
+
// It shares THIS chase's deadline rather than starting one of its own,
|
|
204
|
+
// which matters: a fresh floor-length timer here would start at landing
|
|
205
|
+
// rather than at the restore, so the clamped path could hold anchoring
|
|
206
|
+
// off for nearly twice the floor and stop being the tighter of the two
|
|
207
|
+
// windows, which is the whole reason for the bound. `stop` owns the
|
|
208
|
+
// release, so the existing timer and the input listeners close it.
|
|
209
|
+
releaseLanded = suppressScrollAnchoring();
|
|
210
|
+
// Deliberately NOT `stop()`: the window has to outlive the landing, up to
|
|
211
|
+
// the deadline already running.
|
|
212
|
+
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
rafId = requestAnimationFrame(tick);
|
|
216
|
+
};
|
|
217
|
+
cancelScrollCatchUp = stop;
|
|
218
|
+
// Same inputs that close a suppression window: the reader has taken over.
|
|
219
|
+
for (const ev of ANCHOR_RELEASE_EVENTS) {
|
|
220
|
+
window.addEventListener(ev, stop, { capture: true, passive: true });
|
|
221
|
+
}
|
|
222
|
+
// Bounded by the FLOOR, not the ceiling. The ceiling is a backstop for a hung
|
|
223
|
+
// fetch; this is a scroll WRITE, so its window is the one thing that decides
|
|
224
|
+
// whether a reader can be moved without asking. Any growth past the target
|
|
225
|
+
// fires it, and growth is not exclusively the restore settling: a
|
|
226
|
+
// <webjs-suspense> boundary resolving, a lazy component entering, or a late
|
|
227
|
+
// image would all qualify. Holding it open for the full ceiling would mean a
|
|
228
|
+
// reader who landed and started READING, and so generates no input to cancel
|
|
229
|
+
// it, could be scrolled up to two seconds after pressing Back. The floor
|
|
230
|
+
// covers the restore's own settling, which is what it is for, and is measured
|
|
231
|
+
// in a few hundred milliseconds rather than seconds.
|
|
232
|
+
timer = setTimeout(stop, ANCHOR_SUPPRESS_FLOOR_MS);
|
|
233
|
+
rafId = requestAnimationFrame(tick);
|
|
234
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
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 }} 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
|
+
};
|
|
45
|
+
snapshotCache.set(key, snap);
|
|
46
|
+
while (snapshotCache.size > SNAPSHOT_CAP) {
|
|
47
|
+
const oldest = snapshotCache.keys().next().value;
|
|
48
|
+
snapshotCache.delete(oldest);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Look up a cached snapshot by URL. Returns a normalized Snapshot or
|
|
54
|
+
* null. Tolerates legacy string entries (e.g. from test fixtures that
|
|
55
|
+
* `_snapshotCache.set('/x', 'snap')`).
|
|
56
|
+
*
|
|
57
|
+
* @param {string} url
|
|
58
|
+
* @returns {Snapshot | null}
|
|
59
|
+
*/
|
|
60
|
+
export function snapshotGet(url) {
|
|
61
|
+
const key = cacheKey(url);
|
|
62
|
+
const v = snapshotCache.get(key);
|
|
63
|
+
if (v == null) return null;
|
|
64
|
+
// Move-to-front.
|
|
65
|
+
snapshotCache.delete(key);
|
|
66
|
+
snapshotCache.set(key, v);
|
|
67
|
+
if (typeof v === 'string') return { html: v, scrollX: 0, scrollY: 0 };
|
|
68
|
+
return v;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The cache key for a URL, optionally in a `<webjs-frame>` dimension (#1407).
|
|
73
|
+
*
|
|
74
|
+
* A frame response is SLICED by the `x-webjs-frame` request header and the
|
|
75
|
+
* server marks it `Vary: X-Webjs-Frame`, so a client-side cache of that
|
|
76
|
+
* response has to carry the same dimension or a frame subtree and a page
|
|
77
|
+
* fragment for one URL alias onto each other.
|
|
78
|
+
*
|
|
79
|
+
* The delimiter is a single SPACE, which cannot occur in either half: the URL
|
|
80
|
+
* parser percent-encodes U+0020 in both the path and the query, and an HTML
|
|
81
|
+
* `id` may not contain ASCII whitespace. So a framed key always holds a space
|
|
82
|
+
* and an unframed one never does, and even a non-conforming id set through the
|
|
83
|
+
* DOM stays unambiguous, because the space-free URL half means the LAST space
|
|
84
|
+
* separates the two.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} url
|
|
87
|
+
* @param {string | null} [frameId]
|
|
88
|
+
*/
|
|
89
|
+
export function cacheKey(url, frameId) {
|
|
90
|
+
const u = new URL(url, location.href);
|
|
91
|
+
const path = u.pathname + u.search;
|
|
92
|
+
return frameId ? `${frameId} ${path}` : path;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ====================================================================
|
|
96
|
+
* Navigation
|
|
97
|
+
* ==================================================================== */
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client router: stream.
|
|
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 { warnDropped } from './diagnostics.js';
|
|
10
|
+
import { upgradeTree } from './upgrade.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Forward streamed Suspense resolver templates from the fetched body to
|
|
14
|
+
* the live body. Needed when the new page emits a Suspense boundary that
|
|
15
|
+
* resolves later.
|
|
16
|
+
*
|
|
17
|
+
* @param {HTMLElement} fetchedBody
|
|
18
|
+
*/
|
|
19
|
+
export function forwardSuspenseResolvers(fetchedBody) {
|
|
20
|
+
for (const tpl of fetchedBody.querySelectorAll('template[data-webjs-resolve]')) {
|
|
21
|
+
const clone = /** @type {HTMLTemplateElement} */ (tpl.cloneNode(true));
|
|
22
|
+
document.body.appendChild(clone);
|
|
23
|
+
// Resolve SYNCHRONOUSLY against the just-swapped DOM instead of relying on
|
|
24
|
+
// the inline MutationObserver. The observer fires on a microtask, which
|
|
25
|
+
// races an async `startViewTransition` swap: with view transitions on, the
|
|
26
|
+
// swap that places the `#<id>` placeholder is deferred a frame, so the
|
|
27
|
+
// observer ran first, found no placeholder, and the skeleton stuck (#1048).
|
|
28
|
+
// Called from INSIDE the swap thunk (below), the placeholder is already in
|
|
29
|
+
// the DOM here, so this replaces it within the same commit (and inside any
|
|
30
|
+
// wrapping view transition, so the transition captures the resolved
|
|
31
|
+
// content, not the fallback). Falls back to the observer if the page-level
|
|
32
|
+
// resolver global is somehow absent.
|
|
33
|
+
const id = clone.getAttribute('data-webjs-resolve');
|
|
34
|
+
const resolve = /** @type {any} */ (window).__webjsResolve;
|
|
35
|
+
if (id && typeof resolve === 'function') resolve(id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read a navigation response body progressively (#473). Returns the SHELL
|
|
41
|
+
* (the HTML up to the first streamed Suspense boundary template) as soon as it
|
|
42
|
+
* is available, so the router can swap it in immediately and the user sees the
|
|
43
|
+
* fallbacks without waiting for the slow boundary. When the body carries
|
|
44
|
+
* streamed boundaries it also returns the still-open `reader` + leftover buffer
|
|
45
|
+
* so the caller applies each boundary progressively AFTER the shell swap. A body
|
|
46
|
+
* with no boundaries reads to completion and returns the whole thing, so a
|
|
47
|
+
* non-streaming navigation is behaviourally identical to `resp.text()`.
|
|
48
|
+
*
|
|
49
|
+
* @param {Response} resp
|
|
50
|
+
* @returns {Promise<{ shell: string, streaming: boolean, reader?: ReadableStreamDefaultReader<Uint8Array>, dec?: TextDecoder, rest?: string }>}
|
|
51
|
+
*/
|
|
52
|
+
export async function readStreamedShell(resp) {
|
|
53
|
+
if (!resp.body || typeof resp.body.getReader !== 'function') {
|
|
54
|
+
return { shell: await resp.text(), streaming: false };
|
|
55
|
+
}
|
|
56
|
+
const reader = resp.body.getReader();
|
|
57
|
+
const dec = new TextDecoder();
|
|
58
|
+
let buf = '';
|
|
59
|
+
const MARK = '<template data-webjs-resolve';
|
|
60
|
+
// The SSR stream flushes the whole shell (prefix + body with fallbacks)
|
|
61
|
+
// followed by a `<!--wj-stream-shell-->` sentinel in the SAME chunk, then
|
|
62
|
+
// PAUSES for the slow data before streaming each boundary template and the
|
|
63
|
+
// `</body></html>` closer. The sentinel is what lets the shell swap in
|
|
64
|
+
// immediately instead of blocking until the slow boundary arrives. Fallbacks
|
|
65
|
+
// for robustness: an already-buffered boundary marker (a fast boundary), or
|
|
66
|
+
// `</html>` (a fully-buffered response that happens to carry boundaries).
|
|
67
|
+
const SHELL = '<!--wj-stream-shell-->';
|
|
68
|
+
const HTML_CLOSE = /<\/html\s*>/i;
|
|
69
|
+
for (;;) {
|
|
70
|
+
const { value, done } = await reader.read();
|
|
71
|
+
if (value) buf += dec.decode(value, { stream: true });
|
|
72
|
+
if (done) buf += dec.decode();
|
|
73
|
+
const si = buf.indexOf(SHELL);
|
|
74
|
+
if (si !== -1) {
|
|
75
|
+
return { shell: buf.slice(0, si), streaming: true, reader: done ? null : reader, dec, rest: buf.slice(si + SHELL.length) };
|
|
76
|
+
}
|
|
77
|
+
const mi = buf.indexOf(MARK);
|
|
78
|
+
if (mi !== -1) {
|
|
79
|
+
return { shell: buf.slice(0, mi), streaming: true, reader: done ? null : reader, dec, rest: buf.slice(mi) };
|
|
80
|
+
}
|
|
81
|
+
if (done) {
|
|
82
|
+
// Stream ended with no streaming markers: the whole body is the shell.
|
|
83
|
+
return { shell: buf, streaming: false };
|
|
84
|
+
}
|
|
85
|
+
const hm = HTML_CLOSE.exec(buf);
|
|
86
|
+
if (hm) {
|
|
87
|
+
const end = hm.index + hm[0].length;
|
|
88
|
+
return { shell: buf.slice(0, end), streaming: true, reader, dec, rest: buf.slice(end) };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Extract the next complete top-level
|
|
95
|
+
* `<template data-webjs-resolve="ID">...</template>` unit from `buf`,
|
|
96
|
+
* depth-tracking NESTED `<template>` tags (a streamed shadow component carries a
|
|
97
|
+
* `<template shadowrootmode>` inside). Returns `{ id, content, rest }` for the
|
|
98
|
+
* first complete unit, or null when the closing tag has not streamed in yet.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} buf
|
|
101
|
+
* @returns {{ id: string, content: string, rest: string } | null}
|
|
102
|
+
*/
|
|
103
|
+
export function takeResolveUnit(buf) {
|
|
104
|
+
const m = /<template\s+data-webjs-resolve="([^"]+)"\s*>/i.exec(buf);
|
|
105
|
+
if (!m) return null;
|
|
106
|
+
const id = m[1];
|
|
107
|
+
const contentStart = m.index + m[0].length;
|
|
108
|
+
const tagRe = /<(\/?)template\b[^>]*>/gi;
|
|
109
|
+
tagRe.lastIndex = contentStart;
|
|
110
|
+
let depth = 1;
|
|
111
|
+
let mm;
|
|
112
|
+
while ((mm = tagRe.exec(buf))) {
|
|
113
|
+
if (mm[1] === '/') {
|
|
114
|
+
depth--;
|
|
115
|
+
if (depth === 0) {
|
|
116
|
+
return { id, content: buf.slice(contentStart, mm.index), rest: buf.slice(mm.index + mm[0].length) };
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
depth++;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Apply one streamed Suspense resolution to the live DOM (#473). REPLACES the
|
|
127
|
+
* boundary element (its fallback) with the resolved content and upgrades any
|
|
128
|
+
* custom elements inside. This mirrors the initial-load boot resolver
|
|
129
|
+
* (`b.replaceWith(template.content)`) and the prefetched-buffered path exactly,
|
|
130
|
+
* so a streamed boundary settles to the SAME DOM shape (the transient
|
|
131
|
+
* `<webjs-boundary>` / `<webjs-suspense>` wrapper removed) however the page was
|
|
132
|
+
* reached, in JS so a soft-nav apply does not depend on the inline swap script.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} id
|
|
135
|
+
* @param {string} content
|
|
136
|
+
*/
|
|
137
|
+
export function applyStreamedResolve(id, content) {
|
|
138
|
+
const boundary = document.getElementById(id);
|
|
139
|
+
// A missing boundary is dropped (non-destructive), exactly as before. The
|
|
140
|
+
// async-view-transition race that USED to drop a still-valid boundary (#1048)
|
|
141
|
+
// is handled upstream: `streamBoundariesProgressively` is gated on the swap
|
|
142
|
+
// COMMIT (`_swapCommit`), so the placeholder is already live by the time any
|
|
143
|
+
// resolve is applied. A retry here would run OUTSIDE the streamer's
|
|
144
|
+
// `isCurrent()` nav-token fence and could splice a superseded nav's content
|
|
145
|
+
// into a recycled boundary id, so it is deliberately not attempted.
|
|
146
|
+
if (!boundary) {
|
|
147
|
+
// Dev-only diagnostic (#1051): the drop is benign for the normal reasons (a
|
|
148
|
+
// superseded / degraded / discarded nav), but a stuck skeleton that ISN'T
|
|
149
|
+
// one of those is otherwise silent, which is exactly what made #1048 hard to
|
|
150
|
+
// find. Surface the dropped boundary so a future regression is one glance
|
|
151
|
+
// away. Never in production, never throws, once per id.
|
|
152
|
+
warnDropped(id);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const tpl = document.createElement('template');
|
|
156
|
+
tpl.innerHTML = content;
|
|
157
|
+
const inserted = [...tpl.content.childNodes];
|
|
158
|
+
boundary.replaceWith(tpl.content);
|
|
159
|
+
// Upgrade any custom elements now that they are connected (belt-and-braces:
|
|
160
|
+
// a connected, defined element upgrades on insertion, but a fragment that was
|
|
161
|
+
// built before its module loaded would not).
|
|
162
|
+
for (const n of inserted) if (n.nodeType === 1) upgradeTree(/** @type {Element} */ (n));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Progressively apply streamed Suspense boundaries from an open response reader
|
|
167
|
+
* to the live DOM AFTER the shell has been swapped in (#473). Runs detached
|
|
168
|
+
* (fire-and-forget); each apply is guarded by `isCurrent` so a newer navigation
|
|
169
|
+
* stops it (and cancels the reader). A mid-stream transport failure leaves the
|
|
170
|
+
* already-applied boundaries in place and the rest showing their fallback,
|
|
171
|
+
* which is non-destructive.
|
|
172
|
+
*
|
|
173
|
+
* @param {ReadableStreamDefaultReader<Uint8Array>} reader
|
|
174
|
+
* @param {TextDecoder} dec
|
|
175
|
+
* @param {string} initialBuf
|
|
176
|
+
* @param {() => boolean} isCurrent
|
|
177
|
+
*/
|
|
178
|
+
export async function streamBoundariesProgressively(reader, dec, initialBuf, isCurrent) {
|
|
179
|
+
let buf = initialBuf;
|
|
180
|
+
const flush = () => {
|
|
181
|
+
let unit;
|
|
182
|
+
while ((unit = takeResolveUnit(buf))) {
|
|
183
|
+
if (!isCurrent()) return false;
|
|
184
|
+
applyStreamedResolve(unit.id, unit.content);
|
|
185
|
+
buf = unit.rest;
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
};
|
|
189
|
+
// The whole response was already buffered (the stream ended before the shell
|
|
190
|
+
// delimiter): just apply whatever boundaries are in hand.
|
|
191
|
+
if (!reader) { flush(); return; }
|
|
192
|
+
try {
|
|
193
|
+
for (;;) {
|
|
194
|
+
if (!flush()) { try { await reader.cancel(); } catch { /* ignore */ } return; }
|
|
195
|
+
const { value, done } = await reader.read();
|
|
196
|
+
if (value) buf += dec.decode(value, { stream: true });
|
|
197
|
+
if (done) {
|
|
198
|
+
buf += dec.decode();
|
|
199
|
+
flush();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
/* transport drop mid-stream: leave applied boundaries + remaining fallbacks */
|
|
205
|
+
}
|
|
206
|
+
}
|