@webjsdev/core 0.7.51 → 0.7.54
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 +14 -14
- package/dist/webjs-core-testing.js.map +1 -1
- package/dist/webjs-core.js +7 -7
- package/dist/webjs-core.js.map +3 -3
- package/index.d.ts +8 -2
- package/package.json +1 -1
- 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 +34 -2
- package/src/render-server/template-renderer.js +15 -0
- package/src/repeat.d.ts +15 -0
- package/src/rich-fetch.d.ts +4 -0
- package/src/router-client/constants.js +25 -6
- package/src/router-client/events.js +49 -6
- package/src/router-client/fetch-apply.js +71 -26
- package/src/router-client/navigator.js +272 -101
- package/src/router-client/prefetch.js +11 -4
- package/src/router-client/scroll.js +169 -120
- package/src/router-client/snapshot-cache.js +18 -2
- package/src/router-client/state.js +63 -0
- package/src/router-client/swap.js +7 -4
- package/src/router-client.d.ts +1 -1
- package/src/router-client.js +3 -0
- package/src/suspense.d.ts +9 -0
- package/src/webjs-config.d.ts +58 -0
- package/src/websocket-client.d.ts +22 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
|
-
import { ANCHOR_RELEASE_EVENTS, ANCHOR_SUPPRESS_CEILING_MS,
|
|
9
|
+
import { ANCHOR_RELEASE_EVENTS, ANCHOR_SUPPRESS_CEILING_MS, WRITE_BACK_ARMED_MS } from './constants.js';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Closes the currently open restore window, or null when none is open.
|
|
@@ -58,29 +58,89 @@ export let releaseScrollAnchor = null;
|
|
|
58
58
|
* for the life of the page after their first Back. That is a far worse trade
|
|
59
59
|
* than one repaint, so the root it is.
|
|
60
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.
|
|
61
67
|
* @returns {() => void} Idempotent release. Safe to call after the window has
|
|
62
68
|
* already closed on user input or the ceiling.
|
|
63
69
|
*/
|
|
64
|
-
export function suppressScrollAnchoring() {
|
|
70
|
+
export function suppressScrollAnchoring(targetX, targetY) {
|
|
65
71
|
if (typeof document === 'undefined' || !document.documentElement) return () => {};
|
|
66
72
|
// A second restore inside an open window supersedes the first.
|
|
67
73
|
if (releaseScrollAnchor) releaseScrollAnchor();
|
|
68
74
|
const root = document.documentElement;
|
|
69
75
|
// Save and restore the author's own inline value rather than blanking it,
|
|
70
|
-
// the same contract
|
|
76
|
+
// the same save-and-put-back contract the router keeps elsewhere.
|
|
71
77
|
const prev = root.style.getPropertyValue('overflow-anchor');
|
|
72
78
|
root.style.setProperty('overflow-anchor', 'none');
|
|
73
79
|
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
74
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;
|
|
75
132
|
const release = () => {
|
|
76
133
|
// Only the window that installed this release may close it.
|
|
77
134
|
if (releaseScrollAnchor !== release) return;
|
|
78
135
|
releaseScrollAnchor = null;
|
|
79
136
|
if (timer) { clearTimeout(timer); timer = null; }
|
|
137
|
+
if (armTimer) clearTimeout(armTimer);
|
|
138
|
+
armed = false;
|
|
80
139
|
if (typeof window !== 'undefined') {
|
|
81
140
|
for (const ev of ANCHOR_RELEASE_EVENTS) {
|
|
82
141
|
window.removeEventListener(ev, release, /** @type {any} */ ({ capture: true }));
|
|
83
142
|
}
|
|
143
|
+
if (onScroll) window.removeEventListener('scroll', onScroll);
|
|
84
144
|
}
|
|
85
145
|
if (prev) root.style.setProperty('overflow-anchor', prev);
|
|
86
146
|
else root.style.removeProperty('overflow-anchor');
|
|
@@ -91,22 +151,76 @@ export function suppressScrollAnchoring() {
|
|
|
91
151
|
for (const ev of ANCHOR_RELEASE_EVENTS) {
|
|
92
152
|
window.addEventListener(ev, release, { capture: true, passive: true });
|
|
93
153
|
}
|
|
154
|
+
if (onScroll) window.addEventListener('scroll', onScroll, { passive: true });
|
|
94
155
|
}
|
|
95
156
|
return release;
|
|
96
157
|
}
|
|
97
158
|
|
|
98
159
|
/**
|
|
99
|
-
*
|
|
160
|
+
* Closes the currently held height reservation, or null when none is open.
|
|
100
161
|
* @type {(() => void) | null}
|
|
101
162
|
*/
|
|
102
|
-
export let
|
|
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
|
+
}
|
|
103
217
|
|
|
104
218
|
/**
|
|
105
219
|
* Bumped wherever a restore is superseded (#1310), and read by the one restore
|
|
106
220
|
* path that outlives the call scheduling it. That means a PAGE navigation, a
|
|
107
221
|
* PAGE-level submission, and disabling the router. A frame-targeted nav or
|
|
108
222
|
* submission swaps one region and leaves the page, so it is excluded, exactly
|
|
109
|
-
* like the `loadFrame` case
|
|
223
|
+
* like the `loadFrame` case.
|
|
110
224
|
*
|
|
111
225
|
* Deliberately NOT `currentNavigationToken`, which is the obvious choice and the
|
|
112
226
|
* wrong one: `loadFrame` bumps that too, and its own contract says a frame
|
|
@@ -119,120 +233,6 @@ export let cancelScrollCatchUp = null;
|
|
|
119
233
|
*/
|
|
120
234
|
export let restoreGeneration = 0;
|
|
121
235
|
|
|
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
236
|
/**
|
|
237
237
|
* Run `fn` after two animation frames, so a just-applied DOM has laid out
|
|
238
238
|
* before it reads or acts. Falls back to a macrotask where
|
|
@@ -257,3 +257,52 @@ export function afterTwoFrames(fn) {
|
|
|
257
257
|
export function bumpRestoreGeneration() {
|
|
258
258
|
restoreGeneration += 1;
|
|
259
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
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { SNAPSHOT_CAP } from './constants.js';
|
|
10
10
|
|
|
11
|
-
/** @typedef {{ html: string, scrollX: number, scrollY: number }} Snapshot */
|
|
11
|
+
/** @typedef {{ html: string, scrollX: number, scrollY: number, scrollHeight: number }} Snapshot */
|
|
12
12
|
/** @type {Map<string, Snapshot | string>} */
|
|
13
13
|
export const snapshotCache = new Map();
|
|
14
14
|
|
|
@@ -41,6 +41,15 @@ export function snapshotCurrent(url) {
|
|
|
41
41
|
html: document.documentElement.outerHTML,
|
|
42
42
|
scrollX: typeof window !== 'undefined' ? window.scrollX || 0 : 0,
|
|
43
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,
|
|
44
53
|
};
|
|
45
54
|
snapshotCache.set(key, snap);
|
|
46
55
|
while (snapshotCache.size > SNAPSHOT_CAP) {
|
|
@@ -64,7 +73,14 @@ export function snapshotGet(url) {
|
|
|
64
73
|
// Move-to-front.
|
|
65
74
|
snapshotCache.delete(key);
|
|
66
75
|
snapshotCache.set(key, v);
|
|
67
|
-
|
|
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 };
|
|
68
84
|
return v;
|
|
69
85
|
}
|
|
70
86
|
|
|
@@ -90,3 +90,66 @@ export let enabled = false;
|
|
|
90
90
|
export function _setEnabled(v) {
|
|
91
91
|
enabled = v;
|
|
92
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; }
|
|
@@ -51,10 +51,13 @@ export let _swapCommit = Promise.resolve();
|
|
|
51
51
|
* DOM mutation, for the same reason `ingestSeeds` is: this function can still
|
|
52
52
|
* decide to throw the response away after parsing it, and a `pushState`
|
|
53
53
|
* issued for a swap that never happened is worse than a late one. It runs
|
|
54
|
-
* BEFORE the incoming document replaces what is on screen,
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
54
|
+
* BEFORE the incoming document replaces what is on screen, so the entry for
|
|
55
|
+
* the page being left is finalized while that page is still the live
|
|
56
|
+
* document (#1406, and Turbo Drive's ordering).
|
|
57
|
+
*
|
|
58
|
+
* NOTE: #1406 believed this ordering fixed the blank iOS back-swipe
|
|
59
|
+
* preview. It does not, shown on-device in #1428; the cause was
|
|
60
|
+
* `history.scrollRestoration = 'manual'`.
|
|
58
61
|
*
|
|
59
62
|
* "Before the content swap" is the precise claim, NOT "before anything on
|
|
60
63
|
* the page has changed". The page may already have been mutated by the time
|
package/src/router-client.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Route } from './routes.js';
|
|
|
2
2
|
|
|
3
3
|
export function enableClientRouter(): void;
|
|
4
4
|
export function disableClientRouter(): void;
|
|
5
|
-
export function navigate(url: Route, opts?: { replace?: boolean }): Promise<void>;
|
|
5
|
+
export function navigate(url: Route, opts?: { replace?: boolean; scroll?: boolean }): Promise<void>;
|
|
6
6
|
export function loadFrame(
|
|
7
7
|
frameEl: Element,
|
|
8
8
|
url: string,
|
package/src/router-client.js
CHANGED
|
@@ -95,6 +95,9 @@ export {
|
|
|
95
95
|
prefetchSuppressed as _prefetchSuppressed,
|
|
96
96
|
prefetchTake as _prefetchTake,
|
|
97
97
|
} from './router-client/prefetch.js';
|
|
98
|
+
export {
|
|
99
|
+
resolvePreserveScroll as _resolvePreserveScroll,
|
|
100
|
+
} from './router-client/scroll.js';
|
|
98
101
|
export {
|
|
99
102
|
snapshotCache as _snapshotCache,
|
|
100
103
|
} from './router-client/snapshot-cache.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface SuspenseBoundary {
|
|
2
|
+
_$webjsSuspense: true;
|
|
3
|
+
fallback: unknown;
|
|
4
|
+
children: unknown;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function Suspense(props: { fallback: unknown; children: unknown | Promise<unknown> }): SuspenseBoundary;
|
|
8
|
+
export function isSuspense(x: unknown): x is SuspenseBoundary;
|
|
9
|
+
export const SUSPENSE: unique symbol;
|
package/src/webjs-config.d.ts
CHANGED
|
@@ -144,6 +144,46 @@ export interface WebjsStartTasks {
|
|
|
144
144
|
before?: string[];
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/** One command step of `webjs ci` (#1471). */
|
|
148
|
+
export interface WebjsCiCommand {
|
|
149
|
+
/** Shown in the step heading and its result line. */
|
|
150
|
+
title: string;
|
|
151
|
+
/** The shell command, run with `node_modules/.bin` on PATH and `CI=true` in the environment. */
|
|
152
|
+
run: string;
|
|
153
|
+
/** Extra environment for this step only, e.g. `{ WEBJS_E2E: '1' }`. */
|
|
154
|
+
env?: Record<string, string>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A step inside a group: a nested group takes ONE slot of its parent and runs sequentially, so it cannot declare `parallel`. */
|
|
158
|
+
export interface WebjsCiNestedGroup {
|
|
159
|
+
/** The group name. */
|
|
160
|
+
title: string;
|
|
161
|
+
/** The group's steps, run in order. */
|
|
162
|
+
steps: WebjsCiNestedStep[];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A top-level group of `webjs ci` steps. */
|
|
166
|
+
export interface WebjsCiGroup extends WebjsCiNestedGroup {
|
|
167
|
+
/** How many steps run at once (default 1, sequential). Captured output is replayed whole per step, never interleaved. */
|
|
168
|
+
parallel?: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A top-level `webjs ci` step: a string is shorthand for a command whose title is the command. */
|
|
172
|
+
export type WebjsCiStep = string | WebjsCiCommand | WebjsCiGroup;
|
|
173
|
+
|
|
174
|
+
/** A step inside a group: the same shapes, minus `parallel` on a nested group. */
|
|
175
|
+
export type WebjsCiNestedStep = string | WebjsCiCommand | WebjsCiNestedGroup;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Local CI in `webjs.ci` (#1471): the step list `webjs ci` runs, and the same
|
|
179
|
+
* list a cloud pipeline runs through `npm run ci`. Read by the CLI
|
|
180
|
+
* (`packages/cli/lib/ci-config.js`), not the server.
|
|
181
|
+
*/
|
|
182
|
+
export interface WebjsCiConfig {
|
|
183
|
+
/** The steps, run in order. A failing step fails the run. Required: a block with no steps is refused, since a run of zero steps would read as green. */
|
|
184
|
+
steps: WebjsCiStep[];
|
|
185
|
+
}
|
|
186
|
+
|
|
147
187
|
/**
|
|
148
188
|
* A severity a `webjs.doctor.gate` entry may declare, mirroring ESLint's
|
|
149
189
|
* three-level scale. `error` fails the `webjs doctor` exit, `warn` reports
|
|
@@ -227,6 +267,24 @@ export interface WebjsConfig {
|
|
|
227
267
|
dev?: WebjsDevTasks;
|
|
228
268
|
start?: WebjsStartTasks;
|
|
229
269
|
|
|
270
|
+
/**
|
|
271
|
+
* The `webjs db` verb map (#1468), bring-your-own-ORM. Each key is a verb
|
|
272
|
+
* and its value the shell command `webjs db <verb>` runs instead of the
|
|
273
|
+
* drizzle-kit default (node_modules/.bin on PATH, extra args appended), so
|
|
274
|
+
* `webjs db migrate` keeps one spelling across ORMs:
|
|
275
|
+
* `{ migrate: 'prisma migrate deploy' }`. Any key is a verb; an unmapped one
|
|
276
|
+
* keeps its default, so an app with no block is unchanged. Read by the CLI
|
|
277
|
+
* (`packages/cli/lib/app-tasks.js`), NOT the server.
|
|
278
|
+
*/
|
|
279
|
+
db?: Record<string, string>;
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Local CI (#1471): the step list `webjs ci` runs locally and a cloud
|
|
283
|
+
* pipeline runs through `npm run ci`, so the two cannot drift. Read by the
|
|
284
|
+
* CLI (`packages/cli/lib/ci-config.js`), NOT the server.
|
|
285
|
+
*/
|
|
286
|
+
ci?: WebjsCiConfig;
|
|
287
|
+
|
|
230
288
|
/**
|
|
231
289
|
* `webjs doctor` policy (#1257): which project-health checks the project
|
|
232
290
|
* treats as fatal. Read by the CLI (`packages/cli/lib/doctor.js`), NOT the
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface ConnectOptions {
|
|
2
|
+
onOpen?: (ev: Event) => void;
|
|
3
|
+
// `any`, matching the JSDoc, and load-bearing rather than lazy: the socket
|
|
4
|
+
// delivers an arbitrary JSON payload, and the contract is that the CALLER
|
|
5
|
+
// names the shape it expects (`(msg: ChatMessage) => ...`). Narrowing this to
|
|
6
|
+
// `unknown` type-checks here and breaks every such handler, which is not a
|
|
7
|
+
// change a PR filling in missing declarations gets to make.
|
|
8
|
+
onMessage?: (data: any, ev: MessageEvent) => void;
|
|
9
|
+
onClose?: (ev: CloseEvent) => void;
|
|
10
|
+
onError?: (ev: Event) => void;
|
|
11
|
+
protocols?: string | string[];
|
|
12
|
+
reconnect?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface WSConnection {
|
|
16
|
+
send(data: string | ArrayBuffer | ArrayBufferView | object): void;
|
|
17
|
+
close(code?: number, reason?: string): void;
|
|
18
|
+
readonly socket: WebSocket | null;
|
|
19
|
+
readonly readyState: 0 | 1 | 2 | 3;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function connectWS(url: string, opts?: ConnectOptions): WSConnection;
|