@webjsdev/core 0.7.51 → 0.7.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * @module
8
8
  */
9
- import { ANCHOR_RELEASE_EVENTS, ANCHOR_SUPPRESS_CEILING_MS, ANCHOR_SUPPRESS_FLOOR_MS } from './constants.js';
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 `prevScrollRestoration` keeps above.
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
- * Cancels an in-flight catch-up, or null when none is running.
160
+ * Closes the currently held height reservation, or null when none is open.
100
161
  * @type {(() => void) | null}
101
162
  */
102
- export let cancelScrollCatchUp = null;
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 below.
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
- if (typeof v === 'string') return { html: v, scrollX: 0, scrollY: 0 };
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, because WebKit
55
- * binds a same-document entry's back-forward gesture snapshot to the page
56
- * state at the moment the entry is recorded, and a snapshot taken against
57
- * the incoming (often shorter) document previews blank.
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
@@ -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,
@@ -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;
@@ -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;