bunnyquery 1.8.15 → 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,22 +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
153
  /** Pin to the bottom, instantly, recording the write. The ONLY way to pin. */
157
154
  pinBottom: () => void;
158
- /** The box is not being painted (hidden tab). Shared so hosts agree. */
159
- isFrozen: () => boolean;
160
- /** Deprecated alias of settleReturn, kept so a stale dist does not break. */
161
- thaw: () => void;
162
155
  /** Absorb one element's own resize. See below. */
163
156
  absorb: (el: AnchorGrowableEl | null | undefined) => void;
164
157
  /** Drop the remembered place (chat switch, unmount). */
@@ -205,6 +198,8 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
205
198
  var kids = box.children;
206
199
  var fallback: RowAnchor | null = null;
207
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 }> = [];
208
203
  for (var i = 0; i < kids.length; i++) {
209
204
  var el = kids[i];
210
205
  if (!el || typeof el.getAttribute !== 'function') continue;
@@ -213,7 +208,18 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
213
208
  var top = el.getBoundingClientRect().top - boxTop;
214
209
  // Rows still (partly) on screen. `top` is negative when a row starts
215
210
  // above the fold, which is exactly the offset to preserve.
216
- 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
+ }
217
223
  // And STOP at the bottom of the viewport. Without this the preference
218
224
  // for an ordinary row walks straight past a screenful of collapsed
219
225
  // indexing rows and anchors on a message two screens down — which is
@@ -234,20 +240,29 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
234
240
  scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: el,
235
241
  };
236
242
  if (rawPos === null) {
237
- // An ordinary row: use it, and take a couple of standbys from the rows
238
- // after it in the same walk.
239
- 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);
240
249
  return cand;
241
250
  }
242
251
  if (!fallback) { fallback = cand; fallbackAt = i; } // group row: last resort
243
252
  }
244
253
  // The weak anchor needs standbys MORE than the strong one does, not less: a
245
254
  // screenful of collapsed indexing rows is exactly what a background sweep
246
- // re-keys and re-anchors, and without them state (c) fell straight into
247
- // lost()'s whole-list guess. Ordinary rows only — more group rows from the
248
- // 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.
249
261
  if (fallback) {
250
- 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
+ );
251
266
  return fallback;
252
267
  }
253
268
  return {
@@ -280,6 +295,15 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
280
295
  return out.length ? out : undefined;
281
296
  }
282
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
+
283
307
  function findRow(box: AnchorBoxEl, anchor: RowAnchor): AnchorRowEl | null {
284
308
  // The same node, still in the box: one rect read instead of a scan. Vue
285
309
  // patches keyed rows in place, so this is the common path there, and it is
@@ -296,103 +320,69 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
296
320
  return null;
297
321
  }
298
322
 
299
- // A frozen stretch happened and has not been settled yet. hold() has to know,
300
- // because its own safety rule cannot survive one: on the first call after the
301
- // tab comes forward it must put the reader back rather than conclude they moved
302
- // (and, worse, re-measure — which is what DESTROYS the only record of where they
303
- // were, and why this cannot be left to whoever calls thaw() first).
304
- var sawFrozen = false;
305
- // Where the reader was when they went away. Held apart from `held` because the
306
- // ordinary compensation keeps re-stamping that one while they are gone.
307
- var parked: RowAnchor | null = null;
308
- // They left pinned to the bottom. capture() records nothing for such a reader
309
- // (there is no row to hold, the bottom IS the place), so this is the only note
310
- // of it — and without it a stickiness lost during the absence had no fallback.
311
- var parkedStuck = false;
312
- // A return is armed: it has not landed yet. It survives across BOTH halves of a
313
- // head refresh, which is what puts a pinned reader on the bottom that exists
314
- // after the deferred batch merges rather than the surface page's bottom.
315
- var returning = false;
316
- // The last scrollTop THIS module wrote. A scroll event reporting anything else
317
- // is the reader, and the reader always wins — that is the whole retirement rule
318
- // for an armed return, and it is why every write below records it.
319
- var wroteTop = -1;
320
- // Did the last restore's write actually land, and was it a real row pin rather
321
- // than lost()'s guess. A shrink below the reader silently truncates a
322
- // correction, and a return that believes a clamped write succeeded is a reader
323
- // left a few hundred pixels off their line, permanently.
324
- var restoreExact = true;
325
- var restorePinned = false;
326
- function frozen(): boolean {
327
- var f = !!options.isFrozen && options.isFrozen();
328
- if (f) sawFrozen = true;
329
- return f;
330
- }
331
-
332
- function restore(anchor: RowAnchor | null, unbounded?: boolean): void {
333
- // Frozen: leave `held` exactly as it is. It is the reader's last real
334
- // position, and it is what hold() puts back when the tab comes forward.
335
- if (frozen()) return;
323
+ function restore(anchor: RowAnchor | null): void {
336
324
  var box = options.getBox();
337
325
  if (!box || !anchor || options.isStuck()) return;
338
326
  var el = findRow(box, anchor);
339
327
  if (el) {
340
- // A row that MOVED (an older page re-anchored a collapsed run to its
341
- // true first pass) must not be pinned: doing so would drag the reader
342
- // along with it, to wherever the run now starts.
343
- // Only compare when BOTH sides actually name a turn. A stub that has since
344
- // learned its anchorId (or lost it) has not moved; it has just started (or
345
- // stopped) being able to answer.
346
- 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
+ //
347
334
  // A relocation disqualifies the ROW, not the whole capture: fall through
348
- // 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
349
336
  // whole-list guess.
337
+ var livePos = el.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
350
338
  if (anchor.pos !== null && anchor.pos !== UNKNOWN_ROW_POS &&
351
339
  livePos !== UNKNOWN_ROW_POS && livePos !== anchor.pos) el = null;
352
340
  }
353
341
  if (el) {
354
342
  var boxTop = box.getBoundingClientRect().top;
355
343
  var delta = (el.getBoundingClientRect().top - boxTop) - anchor.top;
356
- // A row can also be MOVED rather than resized: a background refetch
357
- // that merges a run's passes into the middle of page 1 relocates the
358
- // bubble this anchor is holding, and following it would carry the
359
- // reader across the conversation. A real prepend or in-place growth
360
- // can only ever need a correction on the order of what the list gained,
361
- // so a delta a whole screen beyond that is a relocation, not a resize.
362
- // `unbounded` is the thaw: across a hidden stretch the box may have been
363
- // scrolled anywhere at all (a settling request, a clamp), so a correction
364
- // the size of the whole list is not evidence that the ROW moved — it is
365
- // 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.
366
350
  var slack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
367
- if (!unbounded && (delta > slack || delta < -slack)) { lost(box, anchor); return; }
368
- // Sub-pixel noise is not a jump, and writing scrollTop for it costs a
369
- // scroll event (and a re-layout) on every settle.
370
- var want = box.scrollTop + delta;
371
- if (delta >= 1 || delta <= -1) box.scrollTop += delta;
372
- wroteTop = box.scrollTop;
373
- restorePinned = true;
374
- restoreExact = box.scrollTop >= want - 1 && box.scrollTop <= want + 1;
375
- // This position is now the reader's place, and hold() has to know it:
376
- // a bracketed restore MOVES scrollTop, which is exactly what hold()
377
- // reads as "someone scrolled, my anchor is stale". Without this, every
378
- // image that decodes after a re-render (which is all of them: the list
379
- // is rebuilt with src-less, zero-height previews and hydrated
380
- // afterwards) would find a stale anchor and go uncompensated.
381
- held = {
382
- key: anchor.key, top: anchor.top, pos: anchor.pos,
383
- scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: el,
384
- // Carried, not dropped: one successful restore used to disarm the
385
- // standbys for every later hold.
386
- alts: anchor.alts,
387
- };
388
- 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
+ }
389
375
  }
390
- // 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.
391
378
  // A standby row that IS still there beats lost()'s guess outright.
392
379
  var alts = anchor.alts;
393
380
  for (var ai = 0; alts && ai < alts.length; ai++) {
394
381
  var alt = alts[ai];
395
- 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
+ });
396
386
  if (!ael) continue;
397
387
  if (alt.pos !== null && alt.pos !== UNKNOWN_ROW_POS) {
398
388
  var altLive = ael.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
@@ -401,12 +391,9 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
401
391
  var aboxTop = box.getBoundingClientRect().top;
402
392
  var adelta = (ael.getBoundingClientRect().top - aboxTop) - alt.top;
403
393
  var aslack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
404
- if (!unbounded && (adelta > aslack || adelta < -aslack)) continue;
405
- var awant = box.scrollTop + adelta;
394
+ var amoved = adelta - (anchor.scrollTop - box.scrollTop);
395
+ if (amoved > aslack || amoved < -aslack) continue;
406
396
  if (adelta >= 1 || adelta <= -1) box.scrollTop += adelta;
407
- wroteTop = box.scrollTop;
408
- restorePinned = true;
409
- restoreExact = box.scrollTop >= awant - 1 && box.scrollTop <= awant + 1;
410
397
  held = {
411
398
  key: alt.key, top: alt.top, pos: alt.pos,
412
399
  scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: ael,
@@ -418,13 +405,12 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
418
405
  }
419
406
 
420
407
  /**
421
- * 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.
422
409
  *
423
410
  * What is still known is how much the list GREW, and in the case this branch
424
411
  * exists for — the pager, whose page can carry the very pass that re-anchors a
425
412
  * collapsed row — all of that growth is above the reader. So pay it. Missing it
426
- * costs the reader a whole page of history in one jump, which is the single
427
- * most visible version of this bug.
413
+ * costs a whole page of history in one jump.
428
414
  *
429
415
  * With nothing gained there is nothing to pay, and then the two views differ:
430
416
  * one REBUILDS the list (its teardown clamped scrollTop to 0, so the raw offset
@@ -434,8 +420,8 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
434
420
  function lost(box: AnchorBoxEl, anchor: RowAnchor): void {
435
421
  held = null;
436
422
  var grew = box.scrollHeight - anchor.scrollHeight;
437
- if (grew > 0) { box.scrollTop = anchor.scrollTop + grew; wroteTop = box.scrollTop; return; }
438
- 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;
439
425
  }
440
426
 
441
427
  function preserve<T>(mutate: () => T): T {
@@ -445,34 +431,25 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
445
431
  return result;
446
432
  }
447
433
 
434
+ /** Record the reader's place. Call from the box's scroll handler. */
448
435
  function remember(): void {
449
- // THE retirement rule for an armed return, and the only one. This runs from
450
- // the host's scroll handler, which sees every scroll event; a position this
451
- // module did not write is the reader, and the reader always wins. Checked
452
- // before the frozen bail because it is the one thing allowed to end a return.
453
- var b0 = options.getBox();
454
- if (returning && b0 && b0.scrollTop !== wroteTop) {
455
- parked = null; parkedStuck = false; returning = false;
456
- }
457
- // A scroll event while the tab is hidden is not the reader moving.
458
- if (frozen()) return;
459
436
  held = capture();
460
437
  }
461
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
+ */
462
449
  function hold(): void {
463
- if (frozen()) return;
464
- // Coming out of an absence: nothing that moved this box while nobody was
465
- // looking was the reader, so the parked place simply wins. `returning` keeps
466
- // this true across BOTH halves of a head refresh, so a correction the first
467
- // half's shrink clamped away is retried once the second half re-grows the list.
468
- if (sawFrozen || returning) { settleReturn(); return; }
469
450
  var box = options.getBox();
470
451
  if (!box || options.isStuck()) { held = null; return; }
471
452
  if (!held) { held = capture(); return; }
472
- // Something moved the box on purpose since the anchor was taken — the user
473
- // scrolled, a shrink clamped it, or the browser's own scroll anchoring
474
- // already compensated. Restoring here would undo a move the reader made or
475
- // double-count one already made for us, so re-measure instead.
476
453
  if (box.scrollTop !== held.scrollTop) { held = capture(); return; }
477
454
  // restore() re-stamps `held` with the position it just pinned, so repeated
478
455
  // holds (one image after another finishing) each start from a valid anchor.
@@ -483,23 +460,23 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
483
460
  * Absorb a resize made by ONE element, wherever it sits.
484
461
  *
485
462
  * The row anchor cannot see this case. A reader partway through an assistant
486
- * reply that is taller than the viewport is anchored ON that row, and a
487
- * picture decoding higher up INSIDE it moves every line they are reading
488
- * without moving the row's own top by a pixel. Rows above the fold have the
489
- * same problem in reverse: hold() would fix them, but it cannot be allowed to
490
- * 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.
491
468
  *
492
- * So images go through here instead, and it is the more precise of the two:
493
- * it compensates by the element's own height delta, and only while the
494
- * element's TOP is above the fold — which is exactly the condition for
495
- * "everything the reader can see just moved by this much". An element that
496
- * starts at or below the fold is left alone: it grew on screen, under a line
497
- * 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.
498
475
  *
499
- * The height it last saw is remembered per element, so the caller does not
500
- * have to bracket anything. An element it has never seen counts as zero,
501
- * which is what an <img> measures before it has anything to paint — including
502
- * 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.
503
480
  */
504
481
  function absorb(el: AnchorGrowableEl | null | undefined): void {
505
482
  if (!el) return;
@@ -507,163 +484,44 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
507
484
  if (!box) return;
508
485
  var h = el.offsetHeight;
509
486
  var prev = seen ? seen.get(el) : undefined;
510
- // The baseline is recorded even while frozen (and even while pinned to the
511
- // bottom): what must not happen is the WRITE. An image that decodes in a
512
- // hidden tab has still resized, and forgetting that would make the next
513
- // 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.
514
489
  if (seen) seen.set(el, h);
515
490
  if (options.isStuck()) return;
516
491
  if (prev === undefined) prev = 0;
517
492
  var delta = h - prev;
518
493
  if (delta === 0) return;
519
- if (frozen()) { foldFrozenGrowth(box, el, delta); return; }
520
494
  // The element's own TOP, which a resize never moves: everything BELOW it
521
- // slides by delta, everything above stays. So the reader's first visible
522
- // line moved exactly when that top is above the fold — whether the element
523
- // grew, collapsed, or straddles the fold now that it has grown. When the
524
- // top is at or below the fold the growth happens on screen, at or under a
525
- // 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.
526
497
  if (el.getBoundingClientRect().top >= box.getBoundingClientRect().top) return;
527
498
  box.scrollTop += delta;
528
- wroteTop = box.scrollTop;
529
- // Re-measure the remembered anchor, do not patch it. Its scrollTop is
530
- // stale after that write (hold() would read the difference as "the reader
531
- // scrolled" and throw the anchor away), and so is its offset whenever the
532
- // element that just resized lives INSIDE the anchored row: there the row's
533
- // own top never moved, so scrolling by delta changed the row's offset by
534
- // -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.
535
504
  if (held) held = capture();
536
505
  }
537
506
 
538
507
  /**
539
- * The tab has come forward. Put the reader back on the line they left, whatever
540
- * scrollTop says now.
541
- *
542
- * hold() cannot do this job. Its safety rule is "the anchor is valid only while
543
- * box.scrollTop still equals the value it was captured at", which is what stops
544
- * it dragging a reader back after they scroll themselves — and across a hidden
545
- * stretch that rule points the wrong way. Plenty of things write scrollTop while
546
- * the tab is hidden without going through this module at all (a settling request
547
- * scrolling to the bottom, the browser clamping after a refresh shortened the
548
- * list), so on return the value has moved and hold() concludes the reader moved
549
- * it and gives up — leaving them wherever the last invisible write landed. That
550
- * IS the "somehow placed in the middle".
551
- *
552
- * Nothing that happened while nobody was looking was the reader, so here the
553
- * remembered place simply wins.
554
- */
555
- /**
556
- * The reader is going away. Park the place they are leaving, in a slot that the
557
- * ordinary compensation cannot overwrite.
558
- *
559
- * Not the same thing as freezing. A tab that goes HIDDEN stops being painted, so
560
- * there is nothing to compensate and writing scrollTop is pointless. But a window
561
- * that merely loses focus — the user switched to another application — is usually
562
- * still `document.visibilityState === 'visible'`: nothing fires visibilitychange,
563
- * the page keeps rendering, and the chat keeps mutating underneath a reader who
564
- * is not there. Compensation must keep running for that case (they may still be
565
- * able to SEE the window, on a second monitor or beside the other app), which is
566
- * exactly why `held` cannot be trusted on return: every background correction
567
- * re-stamps it. So the leaving place is parked separately.
568
- */
569
- function park(): void {
570
- parked = capture();
571
- parkedStuck = !!options.isStuck();
572
- returning = true;
573
- }
574
-
575
- /**
576
- * Pin to the bottom, instantly, recording the write.
508
+ * Pin to the bottom, instantly.
577
509
  *
578
- * The ONE way anything is allowed to pin. Instant because a smooth glide fires a
579
- * scroll event per frame at positions that are not the bottom, and the hosts
580
- * 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
581
514
  * animation strands the reader off the bottom permanently, aiming at a target
582
- * that was already stale when the glide started. Recorded because an unrecorded
583
- * write looks like the reader and would retire an armed return early.
515
+ * that was already stale when the glide started.
584
516
  */
585
517
  function pinBottom(): void {
586
518
  var box = options.getBox();
587
519
  if (!box) return;
588
520
  box.scrollTop = box.scrollHeight;
589
- wroteTop = box.scrollTop;
590
- }
591
-
592
- /**
593
- * The reader is back. Put them on the parked place, whatever scrollTop says now.
594
- *
595
- * hold() cannot do this job. Its safety rule is "the anchor is valid only while
596
- * box.scrollTop still equals the value it was captured at", which is what stops
597
- * it dragging a reader back after they scroll themselves — and across an absence
598
- * that rule points the wrong way. Plenty of things write scrollTop while nobody
599
- * is looking (a settling request scrolling to the bottom, the browser clamping
600
- * after a refresh shortened the list), so on return the value has moved and
601
- * hold() concludes the reader moved it and gives up — leaving them wherever the
602
- * last unwatched write landed. That IS the "somehow placed in the middle".
603
- *
604
- * Nothing that happened while they were away was them, so the parked place wins.
605
- */
606
- function settleReturn(): boolean {
607
- if (frozen()) return false;
608
- sawFrozen = false;
609
- // A reader who left pinned has no row to hold; the bottom is the place. Stay
610
- // armed so the NEXT settle re-pins after the deferred batch merges, which is
611
- // the bottom they actually mean.
612
- if (parkedStuck) { pinBottom(); return true; }
613
- var target = parked || held;
614
- restoreExact = true; restorePinned = false;
615
- restore(target, true);
616
- // Keep the parked place when the correction could not land. A phase-1 shrink
617
- // leaves the list at its shortest, so the browser truncates the write and the
618
- // reader ends up short — and phase 2 re-grows the list, making that same
619
- // place reachable again. Believing the clamped write was a success is what
620
- // made the error permanent.
621
- parked = (!restorePinned || !restoreExact) ? target : null;
622
- returning = !!parked;
623
- return false;
624
- }
625
-
626
- function isReturning(): boolean { return returning; }
627
-
628
- /** Deprecated alias. */
629
- function thaw(): void { settleReturn(); }
630
-
631
- /**
632
- * An element resized while nobody was looking. Fold it into the remembered
633
- * places instead of dropping it.
634
- *
635
- * The live box cannot be measured against here: while frozen every compensator
636
- * no-ops, so a prepend or a clamp may have moved the box out from under the
637
- * offsets these anchors were taken at. Judge it in the ANCHOR's own frame
638
- * instead — is this element inside the anchored row, and above the reader's
639
- * line — and adjust the anchor rather than the scroll.
640
- *
641
- * `held` and `parked` can be different rows, so each is folded separately. The
642
- * standbys are deliberately not touched: a row below the growth is re-measured
643
- * by restore() anyway.
644
- */
645
- function foldFrozenGrowth(box: AnchorBoxEl, el: AnchorGrowableEl, delta: number): void {
646
- var elTop = el.getBoundingClientRect().top;
647
- foldInto(box, held, elTop, delta);
648
- foldInto(box, parked, elTop, delta);
649
- }
650
-
651
- function foldInto(box: AnchorBoxEl, a: RowAnchor | null, elTop: number, delta: number): void {
652
- if (!a || a.top >= 0) return; // the row starts at or below the fold
653
- var rowEl = findRow(box, a);
654
- if (!rowEl) return;
655
- var within = elTop - rowEl.getBoundingClientRect().top;
656
- if (within < 0 || within >= rowEl.offsetHeight) return; // outside, or stale
657
- if (within >= -a.top) return; // at or below the reader's own line
658
- a.top -= delta;
659
- a.scrollHeight += delta;
660
521
  }
661
522
 
662
523
  function forget(): void {
663
524
  held = null;
664
- parked = null;
665
- parkedStuck = false;
666
- returning = false;
667
525
  }
668
526
 
669
527
  return {
@@ -672,12 +530,7 @@ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
672
530
  preserve: preserve,
673
531
  remember: remember,
674
532
  hold: hold,
675
- park: park,
676
- settleReturn: settleReturn,
677
- isReturning: isReturning,
678
533
  pinBottom: pinBottom,
679
- isFrozen: frozen,
680
- thaw: thaw,
681
534
  absorb: absorb,
682
535
  forget: forget,
683
536
  };