bunnyquery 1.8.13 → 1.8.15

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.
@@ -0,0 +1,684 @@
1
+ /**
2
+ * Hold the reader's place in the message list.
3
+ *
4
+ * A chat box mutates constantly WITHOUT the reader asking for it: an older page
5
+ * prepends, a poll resolves, an indexing row splices in or changes label, a link
6
+ * chip goes grey, an image preview finishes decoding, the "Fetching history..."
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.
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:
16
+ *
17
+ * preserve(fn) / capture() + restore(a)
18
+ * A mutation you can bracket. Measures immediately before and immediately
19
+ * after, so it is exact even when the mutation tears the list down.
20
+ *
21
+ * 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.
26
+ *
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.
34
+ *
35
+ * DOM-free like the rest of the engine: the element shapes below are structural,
36
+ * so real DOM nodes satisfy them while this file imports nothing from lib.dom.
37
+ */
38
+
39
+ export interface AnchorRect {
40
+ top: number;
41
+ }
42
+
43
+ export interface AnchorRowEl {
44
+ getAttribute(name: string): string | null;
45
+ getBoundingClientRect(): AnchorRect;
46
+ offsetHeight: number;
47
+ parentNode: unknown;
48
+ }
49
+
50
+ /** Anything inside the list that resizes on its own schedule. See absorb(). */
51
+ export interface AnchorGrowableEl {
52
+ getBoundingClientRect(): AnchorRect;
53
+ offsetHeight: number;
54
+ }
55
+
56
+ export interface AnchorBoxEl {
57
+ children: ArrayLike<AnchorRowEl>;
58
+ getBoundingClientRect(): AnchorRect;
59
+ scrollTop: number;
60
+ scrollHeight: number;
61
+ clientHeight: number;
62
+ }
63
+
64
+ export interface RowAnchor {
65
+ /** data-row-key of the anchored row, or null when nothing was anchorable. */
66
+ key: string | null;
67
+ /** Offset of that row from the top of the viewport. Negative above the fold. */
68
+ top: number;
69
+ /** data-row-pos, present only on rows that can RELOCATE (see below). */
70
+ pos: string | null;
71
+ /** scrollTop at capture time. The staleness check, and the raw fallback. */
72
+ scrollTop: number;
73
+ /**
74
+ * scrollHeight at capture time. How much the list GREW is the best available
75
+ * answer when the anchored row itself cannot be found again, and the bound on
76
+ * how far a correction can legitimately be.
77
+ */
78
+ scrollHeight: number;
79
+ /**
80
+ * The anchored element itself. A view that patches in place (Vue) keeps the
81
+ * same node across an update, so restore is one rect read instead of a scan;
82
+ * a view that rebuilds the list (the widget) drops it and falls back to the
83
+ * key. Never trusted without re-checking that it is still in the box.
84
+ */
85
+ el: AnchorRowEl | null;
86
+ /**
87
+ * The next few anchorable rows below the primary, each with its own offset.
88
+ *
89
+ * The primary row does not always survive: a refresh can drop it, a collapsed
90
+ * indexing row can be re-identified, an expanded group can fold. Without a
91
+ * fallback the only thing left is lost(), which guesses from the list's total
92
+ * growth — and total growth includes everything added BELOW the reader, so a
93
+ * merge that lands rows on both sides of them over-pays. A second row that is
94
+ * still there beats any guess, and collecting them costs nothing: capture is
95
+ * already walking these rows.
96
+ */
97
+ alts?: Array<{ key: string; top: number; pos: string | null; el: AnchorRowEl }>;
98
+ }
99
+
100
+ export interface ScrollAnchorOptions {
101
+ /** The scrolling message box, or null when it is not mounted. */
102
+ getBox: () => AnchorBoxEl | null;
103
+ /**
104
+ * The reader is pinned to the bottom. There the bottom IS the anchor and the
105
+ * scrollToBottom* paths own the position, so every method here no-ops.
106
+ */
107
+ 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
+ /**
124
+ * Fall back to the raw scrollTop when the anchored row cannot be found again.
125
+ *
126
+ * For a view that REBUILDS the list (the widget's renderMessages), detaching
127
+ * every child collapses scrollHeight and the browser clamps scrollTop to 0,
128
+ * so the raw offset is strictly better than the clamp it would otherwise be
129
+ * left with. For a view that patches in place (Vue) the browser has already
130
+ * kept a sane position and re-imposing a stale offset is worse than nothing.
131
+ */
132
+ rawFallback?: boolean;
133
+ }
134
+
135
+ export interface ScrollAnchor {
136
+ /** Measure the reader's current place. Null while pinned to the bottom. */
137
+ capture: () => RowAnchor | null;
138
+ /** Put a captured place back. Safe to call with null. */
139
+ restore: (anchor: RowAnchor | null) => void;
140
+ /** capture -> mutate -> restore, for a mutation you can bracket. */
141
+ preserve: <T>(mutate: () => T) => T;
142
+ /** Record the reader's place. Call from the box's scroll handler. */
143
+ remember: () => void;
144
+ /** Put the remembered place back, if it is still the reader's own. */
145
+ 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
+ /** Pin to the bottom, instantly, recording the write. The ONLY way to pin. */
157
+ 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
+ /** Absorb one element's own resize. See below. */
163
+ absorb: (el: AnchorGrowableEl | null | undefined) => void;
164
+ /** Drop the remembered place (chat switch, unmount). */
165
+ forget: () => void;
166
+ }
167
+
168
+ /**
169
+ * A row is anchorable when it carries data-row-key. The bars that are not rows —
170
+ * "Fetching history...", the greeting, the drafting bubble, an expanded group's
171
+ * trailing loader — deliberately carry none, so they are never anchored ON while
172
+ * still being fully covered BY the anchor: they change height above a row that
173
+ * is held in place, and holding it is what absorbs them.
174
+ */
175
+ var ROW_KEY_ATTR = 'data-row-key';
176
+ /**
177
+ * A collapsed indexing row names the turn it currently renders at. It is a WEAK
178
+ * anchor because it can RELOCATE — an older page carrying earlier passes of the
179
+ * same run moves the row itself — and pinning a row while it moves is what would
180
+ * drag the reader along with it. So an ordinary message row is always preferred,
181
+ * and a group row is used only when nothing else is on screen, and then only if
182
+ * it did not move.
183
+ */
184
+ var ROW_POS_ATTR = 'data-row-pos';
185
+ /** How many standby rows capture() records. Two is enough to survive a refresh
186
+ * that drops the reader's own row without paying for a full-list guess. */
187
+ var MAX_ALTS = 2;
188
+ /** How far past the anchor collectAlts will look for them. */
189
+ var ALT_SCAN_LIMIT = 64;
190
+ /** data-row-pos is present but empty: the row cannot say where it is anchored. */
191
+ var UNKNOWN_ROW_POS = '\u0000?';
192
+
193
+ export function createScrollAnchor(options: ScrollAnchorOptions): ScrollAnchor {
194
+ var held: RowAnchor | null = null;
195
+ // Per-element height, so absorb() needs no "before" call from the caller.
196
+ // Weak on purpose: a re-render throws every row away and a strong map would
197
+ // hold the whole conversation's DOM alive behind it.
198
+ var seen: WeakMap<object, number> | null =
199
+ typeof WeakMap === 'function' ? new WeakMap<object, number>() : null;
200
+
201
+ function capture(): RowAnchor | null {
202
+ var box = options.getBox();
203
+ if (!box || options.isStuck()) return null;
204
+ var boxTop = box.getBoundingClientRect().top;
205
+ var kids = box.children;
206
+ var fallback: RowAnchor | null = null;
207
+ var fallbackAt = -1;
208
+ for (var i = 0; i < kids.length; i++) {
209
+ var el = kids[i];
210
+ if (!el || typeof el.getAttribute !== 'function') continue;
211
+ var key = el.getAttribute(ROW_KEY_ATTR);
212
+ if (!key) continue;
213
+ var top = el.getBoundingClientRect().top - boxTop;
214
+ // Rows still (partly) on screen. `top` is negative when a row starts
215
+ // above the fold, which is exactly the offset to preserve.
216
+ if (top + el.offsetHeight <= 0) continue;
217
+ // And STOP at the bottom of the viewport. Without this the preference
218
+ // for an ordinary row walks straight past a screenful of collapsed
219
+ // indexing rows and anchors on a message two screens down — which is
220
+ // not the reader's place, and holds the wrong thing when a row between
221
+ // the two changes height.
222
+ if (top >= box.clientHeight) break;
223
+ // An EMPTY data-row-pos means "this row cannot say where it is anchored
224
+ // yet" — a run:: stub has no anchorId until its real group loads. Treating
225
+ // "" as a position made every stub -> real-group handoff read as a
226
+ // relocation and abort the anchor, which is a background resolution that
227
+ // happens on every fresh open. Empty is normalised to null, which also
228
+ // keeps such a row from being MISTAKEN for an ordinary one: the
229
+ // ordinary/group split is the attribute's PRESENCE, tested first.
230
+ var rawPos = el.getAttribute(ROW_POS_ATTR);
231
+ var pos = rawPos === null ? null : (rawPos || UNKNOWN_ROW_POS);
232
+ var cand: RowAnchor = {
233
+ key: key, top: top, pos: pos,
234
+ scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: el,
235
+ };
236
+ 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);
240
+ return cand;
241
+ }
242
+ if (!fallback) { fallback = cand; fallbackAt = i; } // group row: last resort
243
+ }
244
+ // The weak anchor needs standbys MORE than the strong one does, not less: a
245
+ // 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.
249
+ if (fallback) {
250
+ fallback.alts = collectAlts(box, boxTop, fallbackAt + 1, true);
251
+ return fallback;
252
+ }
253
+ return {
254
+ key: null, top: 0, pos: null,
255
+ scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: null,
256
+ };
257
+ }
258
+
259
+ /** Up to MAX_ALTS anchorable rows starting at `from`, for restore's fallback. */
260
+ function collectAlts(box: AnchorBoxEl, boxTop: number, from: number, ordinaryOnly?: boolean) {
261
+ var out: Array<{ key: string; top: number; pos: string | null; el: AnchorRowEl }> = [];
262
+ var kids = box.children;
263
+ // Bounded: a chat can carry hundreds of collapsed rows below the fold, and
264
+ // this must not become a full-list walk for two standbys.
265
+ var stop = Math.min(kids.length, from + ALT_SCAN_LIMIT);
266
+ for (var i = from; i < stop && out.length < MAX_ALTS; i++) {
267
+ var el = kids[i];
268
+ if (!el || typeof el.getAttribute !== 'function') continue;
269
+ var key = el.getAttribute(ROW_KEY_ATTR);
270
+ if (!key) continue;
271
+ var rawPos = el.getAttribute(ROW_POS_ATTR);
272
+ if (ordinaryOnly && rawPos !== null) continue;
273
+ out.push({
274
+ key: key,
275
+ top: el.getBoundingClientRect().top - boxTop,
276
+ pos: rawPos === null ? null : (rawPos || UNKNOWN_ROW_POS),
277
+ el: el,
278
+ });
279
+ }
280
+ return out.length ? out : undefined;
281
+ }
282
+
283
+ function findRow(box: AnchorBoxEl, anchor: RowAnchor): AnchorRowEl | null {
284
+ // The same node, still in the box: one rect read instead of a scan. Vue
285
+ // patches keyed rows in place, so this is the common path there, and it is
286
+ // what keeps a per-update hold() cheap enough to run on every update.
287
+ var el = anchor.el;
288
+ if (el && el.parentNode === (box as unknown)) return el;
289
+ if (!anchor.key) return null;
290
+ var kids = box.children;
291
+ for (var i = 0; i < kids.length; i++) {
292
+ var kid = kids[i];
293
+ if (!kid || typeof kid.getAttribute !== 'function') continue;
294
+ if (kid.getAttribute(ROW_KEY_ATTR) === anchor.key) return kid;
295
+ }
296
+ return null;
297
+ }
298
+
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;
336
+ var box = options.getBox();
337
+ if (!box || !anchor || options.isStuck()) return;
338
+ var el = findRow(box, anchor);
339
+ 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;
347
+ // 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
349
+ // whole-list guess.
350
+ if (anchor.pos !== null && anchor.pos !== UNKNOWN_ROW_POS &&
351
+ livePos !== UNKNOWN_ROW_POS && livePos !== anchor.pos) el = null;
352
+ }
353
+ if (el) {
354
+ var boxTop = box.getBoundingClientRect().top;
355
+ 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.
366
+ 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;
389
+ }
390
+ // The anchor row is gone: its group collapsed, or the history was replaced.
391
+ // A standby row that IS still there beats lost()'s guess outright.
392
+ var alts = anchor.alts;
393
+ for (var ai = 0; alts && ai < alts.length; ai++) {
394
+ 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 });
396
+ if (!ael) continue;
397
+ if (alt.pos !== null && alt.pos !== UNKNOWN_ROW_POS) {
398
+ var altLive = ael.getAttribute(ROW_POS_ATTR) || UNKNOWN_ROW_POS;
399
+ if (altLive !== UNKNOWN_ROW_POS && altLive !== alt.pos) continue;
400
+ }
401
+ var aboxTop = box.getBoundingClientRect().top;
402
+ var adelta = (ael.getBoundingClientRect().top - aboxTop) - alt.top;
403
+ var aslack = Math.abs(box.scrollHeight - anchor.scrollHeight) + box.clientHeight;
404
+ if (!unbounded && (adelta > aslack || adelta < -aslack)) continue;
405
+ var awant = box.scrollTop + adelta;
406
+ 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
+ held = {
411
+ key: alt.key, top: alt.top, pos: alt.pos,
412
+ scrollTop: box.scrollTop, scrollHeight: box.scrollHeight, el: ael,
413
+ alts: alts.slice(ai + 1),
414
+ };
415
+ return;
416
+ }
417
+ lost(box, anchor);
418
+ }
419
+
420
+ /**
421
+ * The anchored row cannot be held: it is gone, or it relocated.
422
+ *
423
+ * What is still known is how much the list GREW, and in the case this branch
424
+ * exists for — the pager, whose page can carry the very pass that re-anchors a
425
+ * 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.
428
+ *
429
+ * With nothing gained there is nothing to pay, and then the two views differ:
430
+ * one REBUILDS the list (its teardown clamped scrollTop to 0, so the raw offset
431
+ * beats the clamp) and one patches in place (the browser already kept a sane
432
+ * position, so re-imposing a stale offset is worse than nothing).
433
+ */
434
+ function lost(box: AnchorBoxEl, anchor: RowAnchor): void {
435
+ held = null;
436
+ 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; }
439
+ }
440
+
441
+ function preserve<T>(mutate: () => T): T {
442
+ var anchor = capture();
443
+ var result = mutate();
444
+ restore(anchor);
445
+ return result;
446
+ }
447
+
448
+ 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
+ held = capture();
460
+ }
461
+
462
+ 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
+ var box = options.getBox();
470
+ if (!box || options.isStuck()) { held = null; return; }
471
+ 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
+ if (box.scrollTop !== held.scrollTop) { held = capture(); return; }
477
+ // restore() re-stamps `held` with the position it just pinned, so repeated
478
+ // holds (one image after another finishing) each start from a valid anchor.
479
+ restore(held);
480
+ }
481
+
482
+ /**
483
+ * Absorb a resize made by ONE element, wherever it sits.
484
+ *
485
+ * 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.
491
+ *
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.
498
+ *
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.
503
+ */
504
+ function absorb(el: AnchorGrowableEl | null | undefined): void {
505
+ if (!el) return;
506
+ var box = options.getBox();
507
+ if (!box) return;
508
+ var h = el.offsetHeight;
509
+ 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.
514
+ if (seen) seen.set(el, h);
515
+ if (options.isStuck()) return;
516
+ if (prev === undefined) prev = 0;
517
+ var delta = h - prev;
518
+ if (delta === 0) return;
519
+ if (frozen()) { foldFrozenGrowth(box, el, delta); return; }
520
+ // 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.
526
+ if (el.getBoundingClientRect().top >= box.getBoundingClientRect().top) return;
527
+ 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.
535
+ if (held) held = capture();
536
+ }
537
+
538
+ /**
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.
577
+ *
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
581
+ * 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.
584
+ */
585
+ function pinBottom(): void {
586
+ var box = options.getBox();
587
+ if (!box) return;
588
+ 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
+ }
661
+
662
+ function forget(): void {
663
+ held = null;
664
+ parked = null;
665
+ parkedStuck = false;
666
+ returning = false;
667
+ }
668
+
669
+ return {
670
+ capture: capture,
671
+ restore: restore,
672
+ preserve: preserve,
673
+ remember: remember,
674
+ hold: hold,
675
+ park: park,
676
+ settleReturn: settleReturn,
677
+ isReturning: isReturning,
678
+ pinBottom: pinBottom,
679
+ isFrozen: frozen,
680
+ thaw: thaw,
681
+ absorb: absorb,
682
+ forget: forget,
683
+ };
684
+ }