bunnyquery 1.8.16 → 1.8.17

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.
@@ -5,32 +5,54 @@
5
5
  * prepends, a poll resolves, an indexing row splices in or changes label, a link
6
6
  * chip goes grey, an image preview finishes decoding, the "Fetching history..."
7
7
  * bar appears and disappears. Every one of those changes the height of something
8
- * that may sit ABOVE the viewport, and the browser answers by keeping scrollTop —
9
- * which slides the sentence the user was reading out from under them.
8
+ * that may sit ABOVE the viewport, and the browser answers by keeping scrollTop,
9
+ * which slides the sentence the reader was on out from under them.
10
10
  *
11
- * Both clients had their own copy of a row anchor for the ONE case each could
12
- * bracket (agent.vue watched its row-key list, the widget bracketed its full
13
- * re-render). Everything else — anything that changed a height without changing
14
- * the row SET, and everything asynchronous — was uncovered in both. This is the
15
- * single implementation, and it covers both shapes:
11
+ * THE ONE RULE: nothing here stores a position to be applied later. Every method
12
+ * acts at the moment it is called, against the box as it is at that moment, and
13
+ * is finished when it returns.
14
+ *
15
+ * That rule is the whole design, and it was learned the hard way. An earlier
16
+ * version of this module also had a "park the place they left, put them back when
17
+ * they return" half — parked/parkedStuck/returning/returnBudget/sawFrozen, a
18
+ * frozen mode, a retry budget. Every one of those was a stored instruction that
19
+ * some later event would carry out, and the reader's experience of a stored
20
+ * instruction is that the chat throws them somewhere for no reason they can see:
21
+ * they came back from another app, tapped the composer, the on-screen keyboard
22
+ * fired a resize, the resize triggered a fetch, the fetch settled, and the settle
23
+ * dutifully executed an instruction recorded before they ever left. Compensating
24
+ * at the moment of the change needs no such instruction, and a return then needs
25
+ * no handling at all, because nothing moved the reader in the first place.
26
+ *
27
+ * Two shapes, both immediate:
16
28
  *
17
29
  * preserve(fn) / capture() + restore(a)
18
30
  * A mutation you can bracket. Measures immediately before and immediately
19
31
  * after, so it is exact even when the mutation tears the list down.
20
32
  *
21
33
  * remember() + hold()
22
- * A layout change you CANNOT bracket — an image decoding, a font arriving,
23
- * a re-parse triggered from a promise. `remember()` runs from the view's
24
- * scroll handler, so the anchor is always the reader's own last position;
25
- * `hold()` puts that position back whenever something settles.
34
+ * A layout change you CANNOT bracket: an image decoding, a font arriving, a
35
+ * re-parse from a promise. remember() runs from the view's scroll handler,
36
+ * so the anchor is always the reader's own last position.
37
+ *
38
+ * hold() is not an exception to the rule, and the staleness check is why. A height
39
+ * change above the viewport does NOT change scrollTop; the browser preserves it,
40
+ * which is precisely why the content appears to jump. So the remembered anchor is
41
+ * valid exactly while `box.scrollTop` still equals the value it was captured at,
42
+ * i.e. while nothing at all has moved the box. The instant that stops being true
43
+ * it is re-measured, never replayed. hold() can therefore only ever undo a height
44
+ * change that just happened, and can never carry out an old intention.
45
+ *
46
+ * Two more consequences worth stating, because both were once done the other way:
26
47
  *
27
- * The staleness rule is what makes the unbracketed half safe. A layout change
28
- * above the viewport does NOT change scrollTop — the browser preserves it, which
29
- * is precisely why the content appears to jump. So a remembered anchor is still
30
- * valid exactly while `box.scrollTop` equals the value it was captured at. If it
31
- * differs, something moved the box on purpose (the user scrolled, a clamp fired,
32
- * or the browser's own scroll anchoring already compensated), and `hold()`
33
- * re-captures rather than dragging the reader back to a position they left.
48
+ * NO FREEZE. Compensation runs whether or not the tab is visible. Layout and
49
+ * getBoundingClientRect are live in a hidden tab; only painting and rAF stop.
50
+ * Suspending compensation while hidden is what created the need to restore
51
+ * something afterwards.
52
+ *
53
+ * A CLAMP IS ACCEPTED. If the content below the reader shrank, their line is
54
+ * genuinely unreachable and the browser truncates the correction. Retrying that
55
+ * later is how a correction turns into an ambush.
34
56
  *
35
57
  * DOM-free like the rest of the engine: the element shapes below are structural,
36
58
  * so real DOM nodes satisfy them while this file imports nothing from lib.dom.
@@ -105,21 +127,6 @@ export interface ScrollAnchorOptions {
105
127
  * scrollToBottom* paths own the position, so every method here no-ops.
106
128
  */
107
129
  isStuck: () => boolean;
108
- /**
109
- * The reader cannot see this box right now (the tab is hidden), so FREEZE:
110
- * remember where they were and refuse to move them.
111
- *
112
- * A hidden tab still runs everything that mutates the list — a resumed poll, a
113
- * head refresh and its deferred background batch, a settling request — and each
114
- * of those would otherwise write scrollTop against a layout nobody is looking at
115
- * and re-stamp the remembered position on the way through. The reader then comes
116
- * back to wherever the last of those writes happened to land, which is the
117
- * "somehow placed in the middle" they see, and only the NEXT correction puts
118
- * them right. So while frozen, reads still happen but nothing writes and nothing
119
- * re-stamps: the anchor holds the last position the reader actually had, and one
120
- * hold() on return puts them back on it.
121
- */
122
- isFrozen?: () => boolean;
123
130
  /**
124
131
  * Fall back to the raw scrollTop when the anchored row cannot be found again.
125
132
  *
@@ -143,33 +150,8 @@ export interface ScrollAnchor {
143
150
  remember: () => void;
144
151
  /** Put the remembered place back, if it is still the reader's own. */
145
152
  hold: () => void;
146
- /** The reader is going away: park the exact place they are leaving. */
147
- park: () => void;
148
- /**
149
- * They are back. Puts them on the parked place, and STAYS ARMED until it has
150
- * actually landed. Returns true when the host must pin to the bottom instead
151
- * (the reader left pinned), which only the host can do meaningfully.
152
- */
153
- settleReturn: () => boolean;
154
- /** A return is armed: its position, not the host's, decides scrollTop. */
155
- isReturning: () => boolean;
156
- /**
157
- * The reader is driving. Retire any armed return, at once.
158
- *
159
- * Call from the raw gesture handlers (wheel, touchstart, touchmove, keydown),
160
- * which fire synchronously on a real user action and never for a programmatic
161
- * scroll. Waiting for the scroll EVENT is not good enough: a background refresh
162
- * can settle in between, and a settle with a return still armed puts the reader
163
- * back where they were before they left, which lands as "I touched the scroll
164
- * and it threw me somewhere else".
165
- */
166
- release: () => void;
167
153
  /** Pin to the bottom, instantly, recording the write. The ONLY way to pin. */
168
154
  pinBottom: () => void;
169
- /** The box is not being painted (hidden tab). Shared so hosts agree. */
170
- isFrozen: () => boolean;
171
- /** Deprecated alias of settleReturn, kept so a stale dist does not break. */
172
- thaw: () => void;
173
155
  /** Absorb one element's own resize. See below. */
174
156
  absorb: (el: AnchorGrowableEl | null | undefined) => void;
175
157
  /** Drop the remembered place (chat switch, unmount). */
@@ -198,9 +180,6 @@ var ROW_POS_ATTR = 'data-row-pos';
198
180
  var MAX_ALTS = 2;
199
181
  /** How far past the anchor collectAlts will look for them. */
200
182
  var ALT_SCAN_LIMIT = 64;
201
- /** Settles an armed return may act on: the two halves of one head refresh, plus
202
- * one spare for a refresh that was already in flight when the reader left. */
203
- var MAX_RETURN_SETTLES = 3;
204
183
  /** data-row-pos is present but empty: the row cannot say where it is anchored. */
205
184
  var UNKNOWN_ROW_POS = '\u0000?';
206
185
 
@@ -219,6 +198,8 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
219
198
  var kids = box.children;
220
199
  var fallback: RowAnchor | null = null;
221
200
  var fallbackAt = -1;
201
+ // The last few rows passed on the way down, kept as standbys of last resort.
202
+ var behind: Array<{ key: string; top: number; pos: string | null; el: AnchorRowEl }> = [];
222
203
  for (var i = 0; i < kids.length; i++) {
223
204
  var el = kids[i];
224
205
  if (!el || typeof el.getAttribute !== 'function') continue;
@@ -227,7 +208,18 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
227
208
  var top = el.getBoundingClientRect().top - boxTop;
228
209
  // Rows still (partly) on screen. `top` is negative when a row starts
229
210
  // above the fold, which is exactly the offset to preserve.
230
- if (top + el.offsetHeight <= 0) continue;
211
+ if (top + el.offsetHeight <= 0) {
212
+ // Entirely above: not the reader's place, but a free standby. The rect
213
+ // is already in hand, and a row ABOVE the anchor survives the batches
214
+ // that rewrite the ones below it. Anything beats lost()'s guess.
215
+ var bpos = el.getAttribute(ROW_POS_ATTR);
216
+ behind.push({
217
+ key: key, top: top,
218
+ pos: bpos === null ? null : (bpos || UNKNOWN_ROW_POS), el: el,
219
+ });
220
+ if (behind.length > MAX_ALTS) behind.shift();
221
+ continue;
222
+ }
231
223
  // And STOP at the bottom of the viewport. Without this the preference
232
224
  // for an ordinary row walks straight past a screenful of collapsed
233
225
  // indexing rows and anchors on a message two screens down — which is
@@ -248,20 +240,29 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
248
240
  scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: el,
249
241
  };
250
242
  if (rawPos === null) {
251
- // An ordinary row: use it, and take a couple of standbys from the rows
252
- // after it in the same walk.
253
- cand.alts = collectAlts(box, boxTop, i + 1);
243
+ // An ordinary row: use it, and take standbys from the rows after it in
244
+ // the same walk, then the ones already passed. Forward first — they are
245
+ // nearer the reader — but a batch that rewrites everything below the
246
+ // fold leaves the rows above it alone, so backward is the better last
247
+ // measurement.
248
+ cand.alts = withBehind(collectAlts(box, boxTop, i + 1), behind);
254
249
  return cand;
255
250
  }
256
251
  if (!fallback) { fallback = cand; fallbackAt = i; } // group row: last resort
257
252
  }
258
253
  // The weak anchor needs standbys MORE than the strong one does, not less: a
259
254
  // screenful of collapsed indexing rows is exactly what a background sweep
260
- // re-keys and re-anchors, and without them state (c) fell straight into
261
- // lost()'s whole-list guess. Ordinary rows only — more group rows from the
262
- // same block are the very rows the same batch rewrites.
255
+ // re-keys and re-anchors, and without them it fell straight into lost()'s
256
+ // whole-list guess. Ordinary rows are PREFERRED, because they are the ones a
257
+ // background batch does not rewrite — but no standby at all means the guess,
258
+ // and a neighbouring group row is a real measurement that restore() will
259
+ // reject anyway if it relocated. So group rows are the last thing before the
260
+ // guess, not something to hold out against.
263
261
  if (fallback) {
264
- fallback.alts = collectAlts(box, boxTop, fallbackAt + 1, true);
262
+ fallback.alts = withBehind(
263
+ collectAlts(box, boxTop, fallbackAt + 1, true) || collectAlts(box, boxTop, fallbackAt + 1),
264
+ behind,
265
+ );
265
266
  return fallback;
266
267
  }
267
268
  return {
@@ -294,6 +295,15 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
294
295
  return out.length ? out : undefined;
295
296
  }
296
297
 
298
+ /** Forward standbys first, then the rows already passed, newest-first. */
299
+ function withBehind(
300
+ forward: Array<{ key: string; top: number; pos: string | null; el: AnchorRowEl }> | undefined,
301
+ behind: Array<{ key: string; top: number; pos: string | null; el: AnchorRowEl }>,
302
+ ) {
303
+ var out = (forward || []).concat(behind.slice().reverse());
304
+ return out.length ? out : undefined;
305
+ }
306
+
297
307
  function findRow(box: AnchorBoxEl, anchor: RowAnchor): AnchorRowEl | null {
298
308
  // The same node, still in the box: one rect read instead of a scan. Vue
299
309
  // patches keyed rows in place, so this is the common path there, and it is
@@ -310,109 +320,69 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
310
320
  return null;
311
321
  }
312
322
 
313
- // A frozen stretch happened and has not been settled yet. hold() has to know,
314
- // because its own safety rule cannot survive one: on the first call after the
315
- // tab comes forward it must put the reader back rather than conclude they moved
316
- // (and, worse, re-measure — which is what DESTROYS the only record of where they
317
- // were, and why this cannot be left to whoever calls thaw() first).
318
- var sawFrozen = false;
319
- // Where the reader was when they went away. Held apart from `held` because the
320
- // ordinary compensation keeps re-stamping that one while they are gone.
321
- var parked: RowAnchor | null = null;
322
- // They left pinned to the bottom. capture() records nothing for such a reader
323
- // (there is no row to hold, the bottom IS the place), so this is the only note
324
- // of it — and without it a stickiness lost during the absence had no fallback.
325
- var parkedStuck = false;
326
- // A return is armed: it has not landed yet. It survives across BOTH halves of a
327
- // head refresh, which is what puts a pinned reader on the bottom that exists
328
- // after the deferred batch merges rather than the surface page's bottom.
329
- var returning = false;
330
- // How many more settles an armed return may act on. A return exists to survive
331
- // the two halves of one head refresh; beyond that it is a stale instruction
332
- // lying in wait, and the reader has moved on. Bounded so a blur that never got
333
- // a matching focus (clicking into devtools, another window, an iframe — all of
334
- // which fire window blur) cannot arm something that fires minutes later.
335
- var returnBudget = 0;
336
- // The last scrollTop THIS module wrote. A scroll event reporting anything else
337
- // is the reader, and the reader always wins — that is the whole retirement rule
338
- // for an armed return, and it is why every write below records it.
339
- var wroteTop = -1;
340
- // Did the last restore's write actually land, and was it a real row pin rather
341
- // than lost()'s guess. A shrink below the reader silently truncates a
342
- // correction, and a return that believes a clamped write succeeded is a reader
343
- // left a few hundred pixels off their line, permanently.
344
- var restoreExact = true;
345
- var restorePinned = false;
346
- function frozen(): boolean {
347
- var f = !!options.isFrozen && options.isFrozen();
348
- if (f) sawFrozen = true;
349
- return f;
350
- }
351
-
352
- function restore(anchor: RowAnchor | null, unbounded?: boolean): void {
353
- // Frozen: leave `held` exactly as it is. It is the reader's last real
354
- // position, and it is what hold() puts back when the tab comes forward.
355
- if (frozen()) return;
323
+ function restore(anchor: RowAnchor | null): void {
356
324
  var box = options.getBox();
357
325
  if (!box || !anchor || options.isStuck()) return;
358
326
  var el = findRow(box, anchor);
359
327
  if (el) {
360
- // A row that MOVED (an older page re-anchored a collapsed run to its
361
- // true first pass) must not be pinned: doing so would drag the reader
362
- // along with it, to wherever the run now starts.
363
- // Only compare when BOTH sides actually name a turn. A stub that has since
364
- // learned its anchorId (or lost it) has not moved; it has just started (or
365
- // stopped) being able to answer.
366
- var livePos = el.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
328
+ // A row that MOVED (an older page re-anchored a collapsed run to its true
329
+ // first pass) must not be pinned: doing so would drag the reader along
330
+ // with it, to wherever the run now starts. Only compare when BOTH sides
331
+ // actually name a turn — a stub that has since learned its anchorId (or
332
+ // lost it) has not moved, it has just started being able to answer.
333
+ //
367
334
  // A relocation disqualifies the ROW, not the whole capture: fall through
368
- // to the standbys, which is a real measurement, rather than to lost()'s
335
+ // to the standbys, which are a real measurement, rather than to lost()'s
369
336
  // whole-list guess.
337
+ var livePos = el.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
370
338
  if (anchor.pos !== null && anchor.pos !== UNKNOWN_ROW_POS &&
371
339
  livePos !== UNKNOWN_ROW_POS && livePos !== anchor.pos) el = null;
372
340
  }
373
341
  if (el) {
374
342
  var boxTop = box.getBoundingClientRect().top;
375
343
  var delta = (el.getBoundingClientRect().top - boxTop) - anchor.top;
376
- // A row can also be MOVED rather than resized: a background refetch
377
- // that merges a run's passes into the middle of page 1 relocates the
378
- // bubble this anchor is holding, and following it would carry the
379
- // reader across the conversation. A real prepend or in-place growth
380
- // can only ever need a correction on the order of what the list gained,
381
- // so a delta a whole screen beyond that is a relocation, not a resize.
382
- // `unbounded` is the thaw: across a hidden stretch the box may have been
383
- // scrolled anywhere at all (a settling request, a clamp), so a correction
384
- // the size of the whole list is not evidence that the ROW moved — it is
385
- // just how far the reader has to be carried back.
344
+ // A row can also be MOVED rather than resized: a background refetch that
345
+ // merges a run's passes into the middle of page 1 relocates the bubble
346
+ // this anchor is holding, and following it would carry the reader across
347
+ // the conversation. A real prepend or in-place growth can only ever need a
348
+ // correction on the order of what the list gained, so a delta a whole
349
+ // screen beyond that is a relocation, not a resize.
386
350
  var slack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
387
- if (!unbounded && (delta > slack || delta < -slack)) { lost(box, anchor); return; }
388
- // Sub-pixel noise is not a jump, and writing scrollTop for it costs a
389
- // scroll event (and a re-layout) on every settle.
390
- var want = box.scrollTop + delta;
391
- if (delta >= 1 || delta <= -1) box.scrollTop += delta;
392
- wroteTop = box.scrollTop;
393
- restorePinned = true;
394
- restoreExact = box.scrollTop >= want - 1 && box.scrollTop <= want + 1;
395
- // This position is now the reader's place, and hold() has to know it:
396
- // a bracketed restore MOVES scrollTop, which is exactly what hold()
397
- // reads as "someone scrolled, my anchor is stale". Without this, every
398
- // image that decodes after a re-render (which is all of them: the list
399
- // is rebuilt with src-less, zero-height previews and hydrated
400
- // afterwards) would find a stale anchor and go uncompensated.
401
- held = {
402
- key: anchor.key, top: anchor.top, pos: anchor.pos,
403
- scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: el,
404
- // Carried, not dropped: one successful restore used to disarm the
405
- // standbys for every later hold.
406
- alts: anchor.alts,
407
- };
408
- return;
351
+ // Test the relocation in CONTENT coordinates. `delta` is measured in the
352
+ // VIEWPORT, so it also contains however far the box itself moved since the
353
+ // capture — and the widget's teardown clamps scrollTop to 0, which makes
354
+ // delta enormous for a row that never went anywhere. Judging that as a
355
+ // relocation dropped a perfectly good anchor into lost()'s whole-list
356
+ // guess. Subtracting the box's own movement leaves what the ROW did.
357
+ var moved = delta - (anchor.scrollTop - box.scrollTop);
358
+ if (moved > slack || moved < -slack) {
359
+ // The row really did relocate. That disqualifies the ROW, not the
360
+ // capture: fall through to the standbys, which are a real measurement.
361
+ el = null;
362
+ } else {
363
+ // Sub-pixel noise is not a jump, and writing scrollTop for it costs a
364
+ // scroll event (and a re-layout) on every settle.
365
+ if (delta >= 1 || delta <= -1) box.scrollTop += delta;
366
+ held = {
367
+ key: anchor.key, top: anchor.top, pos: anchor.pos,
368
+ scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: el,
369
+ // Carried, not dropped: one successful restore used to disarm the
370
+ // standbys for every later hold.
371
+ alts: anchor.alts,
372
+ };
373
+ return;
374
+ }
409
375
  }
410
- // The anchor row is gone: its group collapsed, or the history was replaced.
376
+ // The anchor row is gone or it relocated: its group collapsed, the history was
377
+ // replaced, a background sweep re-keyed it.
411
378
  // A standby row that IS still there beats lost()'s guess outright.
412
379
  var alts = anchor.alts;
413
380
  for (var ai = 0; alts && ai < alts.length; ai++) {
414
381
  var alt = alts[ai];
415
- var ael = findRow(box, { key: alt.key, top: alt.top, pos: alt.pos, scrollTop: anchor.scrollTop, scrollHeight: anchor.scrollHeight, el: alt.el });
382
+ var ael = findRow(box, {
383
+ key: alt.key, top: alt.top, pos: alt.pos,
384
+ scrollTop: anchor.scrollTop, scrollHeight: anchor.scrollHeight, el: alt.el,
385
+ });
416
386
  if (!ael) continue;
417
387
  if (alt.pos !== null && alt.pos !== UNKNOWN_ROW_POS) {
418
388
  var altLive = ael.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
@@ -421,12 +391,9 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
421
391
  var aboxTop = box.getBoundingClientRect().top;
422
392
  var adelta = (ael.getBoundingClientRect().top - aboxTop) - alt.top;
423
393
  var aslack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
424
- if (!unbounded && (adelta > aslack || adelta < -aslack)) continue;
425
- var awant = box.scrollTop + adelta;
394
+ var amoved = adelta - (anchor.scrollTop - box.scrollTop);
395
+ if (amoved > aslack || amoved < -aslack) continue;
426
396
  if (adelta >= 1 || adelta <= -1) box.scrollTop += adelta;
427
- wroteTop = box.scrollTop;
428
- restorePinned = true;
429
- restoreExact = box.scrollTop >= awant - 1 && box.scrollTop <= awant + 1;
430
397
  held = {
431
398
  key: alt.key, top: alt.top, pos: alt.pos,
432
399
  scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: ael,
@@ -438,13 +405,12 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
438
405
  }
439
406
 
440
407
  /**
441
- * The anchored row cannot be held: it is gone, or it relocated.
408
+ * Neither the anchored row nor any standby survived: it is gone, or it moved.
442
409
  *
443
410
  * What is still known is how much the list GREW, and in the case this branch
444
411
  * exists for — the pager, whose page can carry the very pass that re-anchors a
445
412
  * collapsed row — all of that growth is above the reader. So pay it. Missing it
446
- * costs the reader a whole page of history in one jump, which is the single
447
- * most visible version of this bug.
413
+ * costs a whole page of history in one jump.
448
414
  *
449
415
  * With nothing gained there is nothing to pay, and then the two views differ:
450
416
  * one REBUILDS the list (its teardown clamped scrollTop to 0, so the raw offset
@@ -454,8 +420,8 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
454
420
  function lost(box: AnchorBoxEl, anchor: RowAnchor): void {
455
421
  held = null;
456
422
  var grew = box.scrollHeight - anchor.scrollHeight;
457
- if (grew > 0) { box.scrollTop = anchor.scrollTop + grew; wroteTop = box.scrollTop; return; }
458
- if (options.rawFallback) { box.scrollTop = anchor.scrollTop; wroteTop = box.scrollTop; }
423
+ if (grew > 0) { box.scrollTop = anchor.scrollTop + grew; return; }
424
+ if (options.rawFallback) box.scrollTop = anchor.scrollTop;
459
425
  }
460
426
 
461
427
  function preserve<T>(mutate: () => T): T {
@@ -465,33 +431,25 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
465
431
  return result;
466
432
  }
467
433
 
434
+ /** Record the reader's place. Call from the box's scroll handler. */
468
435
  function remember(): void {
469
- // THE retirement rule for an armed return, and the only one. This runs from
470
- // the host's scroll handler, which sees every scroll event; a position this
471
- // module did not write is the reader, and the reader always wins. Checked
472
- // before the frozen bail because it is the one thing allowed to end a return.
473
- var b0 = options.getBox();
474
- if (returning && b0 && b0.scrollTop !== wroteTop) release();
475
- // A scroll event while the tab is hidden is not the reader moving.
476
- if (frozen()) return;
477
436
  held = capture();
478
437
  }
479
438
 
439
+ /**
440
+ * Put the remembered place back, if it is still the reader's own.
441
+ *
442
+ * The staleness check is what keeps this immediate rather than a stored
443
+ * intention: a height change above the viewport does NOT move scrollTop, so the
444
+ * anchor is valid exactly while scrollTop is still the value it was captured
445
+ * at. Anything else — the reader scrolled, a clamp fired, a pin ran — means the
446
+ * box moved for a reason of its own, and the answer is to re-measure, never to
447
+ * replay.
448
+ */
480
449
  function hold(): void {
481
- if (frozen()) return;
482
- // Coming out of a FROZEN stretch, and only that. An armed return is settled
483
- // by the host, from its focus/visible handler and the refresh settles that
484
- // follow — never from here. hold() runs on every render, including the many
485
- // that happen WHILE the reader is still away, and letting those settle the
486
- // return spent it before they were back to see it.
487
- if (sawFrozen) { settleReturn(); return; }
488
450
  var box = options.getBox();
489
451
  if (!box || options.isStuck()) { held = null; return; }
490
452
  if (!held) { held = capture(); return; }
491
- // Something moved the box on purpose since the anchor was taken — the user
492
- // scrolled, a shrink clamped it, or the browser's own scroll anchoring
493
- // already compensated. Restoring here would undo a move the reader made or
494
- // double-count one already made for us, so re-measure instead.
495
453
  if (box.scrollTop !== held.scrollTop) { held = capture(); return; }
496
454
  // restore() re-stamps `held` with the position it just pinned, so repeated
497
455
  // holds (one image after another finishing) each start from a valid anchor.
@@ -502,23 +460,23 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
502
460
  * Absorb a resize made by ONE element, wherever it sits.
503
461
  *
504
462
  * The row anchor cannot see this case. A reader partway through an assistant
505
- * reply that is taller than the viewport is anchored ON that row, and a
506
- * picture decoding higher up INSIDE it moves every line they are reading
507
- * without moving the row's own top by a pixel. Rows above the fold have the
508
- * same problem in reverse: hold() would fix them, but it cannot be allowed to
509
- * run for an image as well or the two would each pay the same debt.
463
+ * reply taller than the viewport is anchored ON that row, and a picture decoding
464
+ * higher up INSIDE it moves every line they are reading without moving the row's
465
+ * own top by a pixel. Rows above the fold have the same problem in reverse:
466
+ * hold() would fix them, but it cannot be allowed to run for an image as well or
467
+ * the two would each pay the same debt.
510
468
  *
511
- * So images go through here instead, and it is the more precise of the two:
512
- * it compensates by the element's own height delta, and only while the
513
- * element's TOP is above the fold — which is exactly the condition for
514
- * "everything the reader can see just moved by this much". An element that
515
- * starts at or below the fold is left alone: it grew on screen, under a line
516
- * the reader is looking at, and moving them is what would be the jump.
469
+ * So images go through here, and it is the more precise of the two: it
470
+ * compensates by the element's own height delta, and only while the element's
471
+ * TOP is above the fold, which is exactly the condition for "everything the
472
+ * reader can see just moved by this much". An element starting at or below the
473
+ * fold is left alone: it grew on screen, under a line the reader is looking at,
474
+ * and moving them is what would be the jump.
517
475
  *
518
- * The height it last saw is remembered per element, so the caller does not
519
- * have to bracket anything. An element it has never seen counts as zero,
520
- * which is what an <img> measures before it has anything to paint — including
521
- * the markdown `![alt](url)` images that have no hydration hook at all.
476
+ * The height it last saw is remembered per element, so the caller does not have
477
+ * to bracket anything. An element it has never seen counts as zero, which is
478
+ * what an <img> measures before it has anything to paint — including the
479
+ * markdown images that have no hydration hook at all.
522
480
  */
523
481
  function absorb(el: AnchorGrowableEl | null | undefined): void {
524
482
  if (!el) return;
@@ -526,181 +484,44 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
526
484
  if (!box) return;
527
485
  var h = el.offsetHeight;
528
486
  var prev = seen ? seen.get(el) : undefined;
529
- // The baseline is recorded even while frozen (and even while pinned to the
530
- // bottom): what must not happen is the WRITE. An image that decodes in a
531
- // hidden tab has still resized, and forgetting that would make the next
532
- // visible change pay for its whole height.
487
+ // The baseline is recorded even while pinned to the bottom (where no write is
488
+ // wanted): forgetting it would make the next change pay for this height too.
533
489
  if (seen) seen.set(el, h);
534
490
  if (options.isStuck()) return;
535
491
  if (prev === undefined) prev = 0;
536
492
  var delta = h - prev;
537
493
  if (delta === 0) return;
538
- if (frozen()) { foldFrozenGrowth(box, el, delta); return; }
539
494
  // The element's own TOP, which a resize never moves: everything BELOW it
540
- // slides by delta, everything above stays. So the reader's first visible
541
- // line moved exactly when that top is above the fold — whether the element
542
- // grew, collapsed, or straddles the fold now that it has grown. When the
543
- // top is at or below the fold the growth happens on screen, at or under a
544
- // line the reader is looking at, and moving them would be the jump.
495
+ // slides by delta, everything above stays. So the reader's first visible line
496
+ // moved exactly when that top is above the fold.
545
497
  if (el.getBoundingClientRect().top >= box.getBoundingClientRect().top) return;
546
498
  box.scrollTop += delta;
547
- wroteTop = box.scrollTop;
548
- // Re-measure the remembered anchor, do not patch it. Its scrollTop is
549
- // stale after that write (hold() would read the difference as "the reader
550
- // scrolled" and throw the anchor away), and so is its offset whenever the
551
- // element that just resized lives INSIDE the anchored row: there the row's
552
- // own top never moved, so scrolling by delta changed the row's offset by
553
- // -delta and the next hold() would faithfully undo this correction.
499
+ // Re-measure the remembered anchor, do not patch it. Its scrollTop is stale
500
+ // after that write, and so is its offset whenever the element that resized
501
+ // lives INSIDE the anchored row: there the row's own top never moved, so
502
+ // scrolling by delta changed the row's offset by -delta and the next hold()
503
+ // would faithfully undo this correction.
554
504
  if (held) held = capture();
555
505
  }
556
506
 
557
507
  /**
558
- * The tab has come forward. Put the reader back on the line they left, whatever
559
- * scrollTop says now.
560
- *
561
- * hold() cannot do this job. Its safety rule is "the anchor is valid only while
562
- * box.scrollTop still equals the value it was captured at", which is what stops
563
- * it dragging a reader back after they scroll themselves — and across a hidden
564
- * stretch that rule points the wrong way. Plenty of things write scrollTop while
565
- * the tab is hidden without going through this module at all (a settling request
566
- * scrolling to the bottom, the browser clamping after a refresh shortened the
567
- * list), so on return the value has moved and hold() concludes the reader moved
568
- * it and gives up — leaving them wherever the last invisible write landed. That
569
- * IS the "somehow placed in the middle".
570
- *
571
- * Nothing that happened while nobody was looking was the reader, so here the
572
- * remembered place simply wins.
573
- */
574
- /**
575
- * The reader is going away. Park the place they are leaving, in a slot that the
576
- * ordinary compensation cannot overwrite.
508
+ * Pin to the bottom, instantly.
577
509
  *
578
- * Not the same thing as freezing. A tab that goes HIDDEN stops being painted, so
579
- * there is nothing to compensate and writing scrollTop is pointless. But a window
580
- * that merely loses focus — the user switched to another application — is usually
581
- * still `document.visibilityState === 'visible'`: nothing fires visibilitychange,
582
- * the page keeps rendering, and the chat keeps mutating underneath a reader who
583
- * is not there. Compensation must keep running for that case (they may still be
584
- * able to SEE the window, on a second monitor or beside the other app), which is
585
- * exactly why `held` cannot be trusted on return: every background correction
586
- * re-stamps it. So the leaving place is parked separately.
587
- */
588
- function park(): void {
589
- parked = capture();
590
- parkedStuck = !!options.isStuck();
591
- returning = true;
592
- returnBudget = MAX_RETURN_SETTLES;
593
- }
594
-
595
- /** The reader is driving: an armed return is no longer anyone's business. */
596
- function release(): void {
597
- parked = null;
598
- parkedStuck = false;
599
- returning = false;
600
- returnBudget = 0;
601
- }
602
-
603
- /**
604
- * Pin to the bottom, instantly, recording the write.
605
- *
606
- * The ONE way anything is allowed to pin. Instant because a smooth glide fires a
607
- * scroll event per frame at positions that are not the bottom, and the hosts
608
- * clear stickToBottom on each of them — so any merge landing inside the ~130ms
510
+ * The ONE way anything is allowed to pin, and it reads the bottom at the moment
511
+ * it is called rather than aiming at a remembered one. Instant because a smooth
512
+ * glide fires a scroll event per frame at positions that are not the bottom, and
513
+ * the hosts clear stickToBottom on each of them, so any merge landing inside the
609
514
  * animation strands the reader off the bottom permanently, aiming at a target
610
- * that was already stale when the glide started. Recorded because an unrecorded
611
- * write looks like the reader and would retire an armed return early.
515
+ * that was already stale when the glide started.
612
516
  */
613
517
  function pinBottom(): void {
614
518
  var box = options.getBox();
615
519
  if (!box) return;
616
520
  box.scrollTop = box.scrollHeight;
617
- wroteTop = box.scrollTop;
618
- }
619
-
620
- /**
621
- * The reader is back. Put them on the parked place, whatever scrollTop says now.
622
- *
623
- * hold() cannot do this job. Its safety rule is "the anchor is valid only while
624
- * box.scrollTop still equals the value it was captured at", which is what stops
625
- * it dragging a reader back after they scroll themselves — and across an absence
626
- * that rule points the wrong way. Plenty of things write scrollTop while nobody
627
- * is looking (a settling request scrolling to the bottom, the browser clamping
628
- * after a refresh shortened the list), so on return the value has moved and
629
- * hold() concludes the reader moved it and gives up — leaving them wherever the
630
- * last unwatched write landed. That IS the "somehow placed in the middle".
631
- *
632
- * Nothing that happened while they were away was them, so the parked place wins.
633
- */
634
- function settleReturn(): boolean {
635
- if (frozen()) return false;
636
- var wasFrozen = sawFrozen;
637
- sawFrozen = false;
638
- // Nothing is armed, so there is nothing to settle. Without this the call was
639
- // a licence to re-impose `held` UNBOUNDED at any time — which is exactly how
640
- // a reader who had already taken control got thrown back: they released the
641
- // return, and the next background settle simply used the live anchor instead.
642
- if (!returning && !wasFrozen) return false;
643
- if (returning) {
644
- if (returnBudget <= 0) { release(); return false; }
645
- returnBudget--;
646
- }
647
- // A reader who left pinned has no row to hold; the bottom is the place. Stay
648
- // armed so the NEXT settle re-pins after the deferred batch merges, which is
649
- // the bottom they actually mean.
650
- if (parkedStuck) { pinBottom(); return true; }
651
- var target = parked || held;
652
- restoreExact = true; restorePinned = false;
653
- restore(target, true);
654
- // Keep the parked place when the correction could not land. A phase-1 shrink
655
- // leaves the list at its shortest, so the browser truncates the write and the
656
- // reader ends up short — and phase 2 re-grows the list, making that same
657
- // place reachable again. Believing the clamped write was a success is what
658
- // made the error permanent.
659
- parked = (!restorePinned || !restoreExact) ? target : null;
660
- returning = !!parked && returnBudget > 0;
661
- if (!returning) { parked = null; parkedStuck = false; returnBudget = 0; }
662
- return false;
663
- }
664
-
665
- function isReturning(): boolean { return returning; }
666
-
667
- /** Deprecated alias. */
668
- function thaw(): void { settleReturn(); }
669
-
670
- /**
671
- * An element resized while nobody was looking. Fold it into the remembered
672
- * places instead of dropping it.
673
- *
674
- * The live box cannot be measured against here: while frozen every compensator
675
- * no-ops, so a prepend or a clamp may have moved the box out from under the
676
- * offsets these anchors were taken at. Judge it in the ANCHOR's own frame
677
- * instead — is this element inside the anchored row, and above the reader's
678
- * line — and adjust the anchor rather than the scroll.
679
- *
680
- * `held` and `parked` can be different rows, so each is folded separately. The
681
- * standbys are deliberately not touched: a row below the growth is re-measured
682
- * by restore() anyway.
683
- */
684
- function foldFrozenGrowth(box: AnchorBoxEl, el: AnchorGrowableEl, delta: number): void {
685
- var elTop = el.getBoundingClientRect().top;
686
- foldInto(box, held, elTop, delta);
687
- foldInto(box, parked, elTop, delta);
688
- }
689
-
690
- function foldInto(box: AnchorBoxEl, a: RowAnchor | null, elTop: number, delta: number): void {
691
- if (!a || a.top >= 0) return; // the row starts at or below the fold
692
- var rowEl = findRow(box, a);
693
- if (!rowEl) return;
694
- var within = elTop - rowEl.getBoundingClientRect().top;
695
- if (within < 0 || within >= rowEl.offsetHeight) return; // outside, or stale
696
- if (within >= -a.top) return; // at or below the reader's own line
697
- a.top -= delta;
698
- a.scrollHeight += delta;
699
521
  }
700
522
 
701
523
  function forget(): void {
702
524
  held = null;
703
- release();
704
525
  }
705
526
 
706
527
  return {
@@ -709,13 +530,7 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
709
530
  preserve: preserve,
710
531
  remember: remember,
711
532
  hold: hold,
712
- park: park,
713
- settleReturn: settleReturn,
714
- isReturning: isReturning,
715
- release: release,
716
533
  pinBottom: pinBottom,
717
- isFrozen: frozen,
718
- thaw: thaw,
719
534
  absorb: absorb,
720
535
  forget: forget,
721
536
  };