@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.
Files changed (61) 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 +21 -21
  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 +1 -1
  9. package/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/attribute-reader.js +178 -0
  12. package/src/component/lifecycle.js +1481 -0
  13. package/src/component/reactive.js +171 -0
  14. package/src/component/server-element.js +248 -0
  15. package/src/component.d.ts +10 -0
  16. package/src/component.js +2 -1931
  17. package/src/html-entities.js +2179 -0
  18. package/src/render-client/parts.js +2007 -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 -2939
  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 +923 -0
  28. package/src/render-server/text.js +163 -0
  29. package/src/render-server.js +6 -2334
  30. package/src/router-client/anchors.js +44 -0
  31. package/src/router-client/boundaries.js +221 -0
  32. package/src/router-client/constants.js +187 -0
  33. package/src/router-client/diagnostics.js +377 -0
  34. package/src/router-client/dom-differ.js +709 -0
  35. package/src/router-client/dom-parse.js +150 -0
  36. package/src/router-client/events.js +139 -0
  37. package/src/router-client/fetch-apply.js +387 -0
  38. package/src/router-client/form-encoder.js +135 -0
  39. package/src/router-client/frames.js +329 -0
  40. package/src/router-client/head-merge.js +344 -0
  41. package/src/router-client/nav-error.js +105 -0
  42. package/src/router-client/navigator.js +781 -0
  43. package/src/router-client/prefetch.js +824 -0
  44. package/src/router-client/scroll.js +259 -0
  45. package/src/router-client/snapshot-cache.js +97 -0
  46. package/src/router-client/state.js +92 -0
  47. package/src/router-client/stream.js +206 -0
  48. package/src/router-client/swap.js +451 -0
  49. package/src/router-client/upgrade.js +261 -0
  50. package/src/router-client/view-transition.js +223 -0
  51. package/src/router-client.d.ts +10 -1
  52. package/src/router-client.js +75 -5345
  53. package/src/slot/assignment.js +304 -0
  54. package/src/slot/interception.js +558 -0
  55. package/src/slot/polyfills.js +365 -0
  56. package/src/slot/project.js +432 -0
  57. package/src/slot/sensors.js +303 -0
  58. package/src/slot/state.js +245 -0
  59. package/src/slot/symbols.js +85 -0
  60. package/src/slot.js +7 -2199
  61. 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
+ }