fix(virtualizer): hold the reader by position when a prepend retires every key - #8001
Conversation
a4e3890 to
b7d26b2
Compare
…pin bottom
Two fixes for the refresh-then-walk bounce (parked at the bottom while
the archived-history walk lands pages above).
Headless boundary turn keys on its TAIL. The topmost turn of a
partially loaded transcript has no opening prompt (it is in an unloaded
older page), and virtualKeyFor keyed every turn on its LEAD -- so each
walk landing that fed the boundary turn's head renamed the row. React
treats a renamed row as a new element: the giant row unmounted and
remounted once per landing wave, its Pierre surfaces visibly
re-highlighting ("the file-change section keeps reloading") and its
height-cache line orphaned. A headless turn now keys on its tail
(hlt-<id>), which content arriving above cannot touch, so the key holds
across the whole walk and flips to the lead exactly once when the
opening prompt lands (that one flip's height migrates via the departure
rename pass). Scoped tightly: index 0 only (mid-list interim folds keep
the #253 lead-key contract), never the trailing turn (positional guard)
and never an in-flight turn (complete === false, the semantic guard
that also covers refreshing into a single giant running turn).
Bottom re-pin runs regardless of anchor age. The height-sync
compensation effect gated EVERYTHING behind HEIGHT_ANCHOR_MAX_AGE_MS,
including the stick branch -- but that branch reads only live geometry
(bottomTarget), so staleness cannot mis-correct it, while a landing
wave is exactly when the main thread is busiest and the effect most
likely to run late. A late reprice fell through to the post-paint
pinAuto: one visible frame of displacement per wave. The age gate now
guards only the viewport-relative candidate path (its documented
hazard), and the bottom re-pin always runs pre-paint.
Complementary to upstream #8001 (positional fallback when a prepend
retires every visible key): this change keeps identities alive so that
fallback is needed less; no overlap in the code paths touched.
Audit note — this overlaps #7916 on the same silent stand-down; consolidate rather than land bothThis PR and #7916 delete the same stand-down at the same site — TRIGGER 1 in The overlapBoth PRs delete the SAME silent stand-down at the SAME site: TRIGGER 1 in website/src/hooks/virtualizer/useVirtualChat.ts, where Why #7916 is the one to build onCompleteness on the shared requirement, plus reproduction evidence. On the anchor-miss hole #7916 ships four mechanisms where #8001 ships one: (1) anchor identity moved off display keys onto a new getStableId option (ChatPage wires What was deliberately discountedDiscounted as proving nothing: (a) the merge conflict in useVirtualChat.prependAnchor.test.tsx would be independent on its own — the finding rests on the two PRs flipping the same ASSERTION about the same scenario, not on co-editing a file; (b) #7916's other 62 files (history_projection.py archive pagination, MeasureFarm.tsx, pierreStaging.ts, staleShellHeal.ts + sw.js, five scroll-rigs, scrollQuiet.ts, chatSlice bounding, height-sync staleness cut, split IntersectionObservers) are wholly disjoint from #8001 and were NOT counted as overlap; #7916 also edits ChatPage.tsx, a listed hub file #8001 never touches, so that earned no credit either way; (c) #7932 also edits useVirtualChat.ts but on the stick/follow path ( If #7916's design is kept, harvest this from hereIf #7916's design is kept, harvest from #8001: (1) the observation that a POSITION-derived anchor fed to the existing DOM-measured part-2 path is inherently immune to double-compensation against native CSS scroll anchoring — it measures the row's real post-layout offset — which would let #7916 retire its From a repository-wide duplicate/overlap audit of every pull request open against |
b7d26b2 to
4ccdeeb
Compare
…every key A front growth that lands together with a re-identification of every visible row -- the post-turn refresh rebuilding the transcript wholesale while the reader is scrolled up -- left the prepend trigger with no surviving key to anchor on. The capture stood down, so neither the window nor scrollTop moved while hundreds of rows materialised in front: the viewport, still at its old index range, was showing the START of the transcript. Reported as "the session history suddenly jumps back to the beginning, then I have to click the down arrow", and reproduced in the prepend harness (a 30-row tail behind 1000 new rows: the reader landed on row 27 of 1030). When no visible key survives, the topmost visible row is re-found by POSITION and handed to the same re-base and the same correction the surviving-key case uses. Its displacement is read off the NEAREST row (by old index) whose key did survive, because the net count growth equals a row's displacement only for a pure front insert: a rebuild that also grows the tail (a reconnect catching up on missed rows) would otherwise overshoot the reader by the tail growth. Only when no key survives anywhere does the net count stand in, which keeps the reader's distance from the end. Both paths now re-base by that same measure -- the anchored row's own displacement, SIGNED -- rather than by the net count. Rows coalescing above the reader while the tail grows move them up although the count grew; the old re-base carried the window the wrong way past the anchor and left a blank band. A negative re-base moves the range up, which trigger 2 reads as a window shift, so trigger 2 yields while a re-base is in flight, and part 1 clears the slot on every exit from 'awaiting-rebase' so no anchor can be stranded there. The slot-switch anchor persists key and top only; the capture's index is the outgoing commit's. The test that pinned the stand-down as "acceptably lost" is rewritten to pin the positional hold; further cases cover a wide growth, the index-addressed getKey shape, mixed front/tail growth on both paths, and a negative displacement. Mutation-verified: reverting the hook, mispairing getKey with the previous items, substituting the net count for the displacement on either path, restoring the positive-only gate, and dropping trigger 2's yield each fail a distinct test.
4ccdeeb to
9f85aef
Compare
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS Invisible-by-design virtualizer fix: the reader's scroll position now survives a wholesale transcript rebuild instead of jumping to the top — no new surfaces, strings, or states to review. [UX-REVIEWED] 9f85aef |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Positional fallback lives in the right layer — the virtualizer must tolerate key retirement it can't prevent — and the naive net-count alternative is explicitly analyzed and rejected. [DESIGN-REVIEWED] 9f85aef |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All evidence checked: the root-cause claim about First-Principles-Verdict: CONCERNS The fix sits at the right mechanism, but the identical silent stand-down survives in one sibling capture — the splice path — which this change leaves unfixed. What this change shipsIntent: stop a long transcript from snapping to the session start at turn boundaries — a FIX (two user reports).
Watch
[FIRST-PRINCIPLES-REVIEWED] 9f85aef |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
|
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (2 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: virtualizer holds the reader by position when a prepend retires every key -- one hook plus its regression test.
chenmingwei23
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (2 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean (Semgrep + CodeQL), security checklist all-NO, AI reviewers green. Category: fix - hold the virtualizer reader by position when a prepend retires every key (clear root cause).
Squashed rebase of the chat-history-polish series onto main after upstream #8001 landed in the same TRIGGER 1 site. Archived history: full-chain pagination (backend page endpoints, walk rigs, scrollQuiet landing buffer), reader-initiated loading only (the top sentinel, walk poll and idle prefetch all authorize through shouldAutoFillOlder: real input this session, or a transcript too short to scroll), and MeasureFarm measured-only landings. Scroll stability: height-anchor age gate scoped to the viewport- relative path only (the bottom re-pin reads live geometry and now runs regardless of anchor age); native-anchoring remainder writes; commit- phase prepend mirror (concurrent-render tear); 'ready'-only anchor consumption; display-key rename migration in the height cache; headless boundary-turn tail keying; WarmSwap frozen-height remounts; sticky shell activity line (the per-tool collapse ease was the tool-rhythm bounce); latched running flag for the display layer. TRIGGER 1 consolidation with #8001 (per the audit note): anchor identity prefers getStableId (the row's tail, which survives regroups, so the miss rarely happens); on a miss, #8001's positional re-identification (nearest-survivor displacement, net count when none) re-anchors and feeds the same DOM-measured part-1/part-2 path -- which is inherently immune to double compensation against native scroll anchoring; only when even that finds nothing does the arithmetic fallback compensate (insertedPx minus the native adjustment). Part 1 re-bases by the anchored row's DISPLACEMENT (signed, clamped) rather than the net count, and carries the net separately for the fallbacks. The equal-count swap trigger's render-phase bump is latched per commit: its termination argument assumed a render-phase mirror advance, and the mirror now advances at commit. The wholesale-retirement tests pin the positional path's real invariants: the visual anchor (successor row at the viewport top) and the no-double-compensation upper bound -- not an exact scrollTop, which estimate-vs-real spacer pricing legitimately skews until rows re-measure.
Squashed rebase of the chat-history-polish series onto main after upstream #8001 landed in the same TRIGGER 1 site. Archived history: full-chain pagination (backend page endpoints, walk rigs, scrollQuiet landing buffer), reader-initiated loading only (the top sentinel, walk poll and idle prefetch all authorize through shouldAutoFillOlder: real input this session, or a transcript too short to scroll), and MeasureFarm measured-only landings. Scroll stability: height-anchor age gate scoped to the viewport- relative path only (the bottom re-pin reads live geometry and now runs regardless of anchor age); native-anchoring remainder writes; commit- phase prepend mirror (concurrent-render tear); 'ready'-only anchor consumption; display-key rename migration in the height cache; headless boundary-turn tail keying; WarmSwap frozen-height remounts; sticky shell activity line (the per-tool collapse ease was the tool-rhythm bounce); latched running flag for the display layer. TRIGGER 1 consolidation with #8001 (per the audit note): anchor identity prefers getStableId (the row's tail, which survives regroups, so the miss rarely happens); on a miss, #8001's positional re-identification (nearest-survivor displacement, net count when none) re-anchors and feeds the same DOM-measured part-1/part-2 path -- which is inherently immune to double compensation against native scroll anchoring; only when even that finds nothing does the arithmetic fallback compensate (insertedPx minus the native adjustment). Part 1 re-bases by the anchored row's DISPLACEMENT (signed, clamped) rather than the net count, and carries the net separately for the fallbacks. The equal-count swap trigger's render-phase bump is latched per commit: its termination argument assumed a render-phase mirror advance, and the mirror now advances at commit. The wholesale-retirement tests pin the positional path's real invariants: the visual anchor (successor row at the viewport top) and the no-double-compensation upper bound -- not an exact scrollTop, which estimate-vs-real spacer pricing legitimately skews until rows re-measure. Review-lane fixes folded in: the unconsumed estimateItem/estimateAt per-row pricing option is removed (dead rider; the fix's numbers were measured without it); mint-pod-url validates its argv and resolves the binary before the subprocess call; the two flagged scroll rigs share one boot preamble via rig-lib.mjs; deliberate effect-dependency omissions carry the repo's standard disable-with-reason spelling; test doubles drop `any` casts and unused imports; PR-body screenshot evidence lives under temp-screenshots/chat-history-polish/.
Squashed rebase of the chat-history-polish series onto main after upstream #8001 landed in the same TRIGGER 1 site. Archived history: full-chain pagination (backend page endpoints, walk rigs, scrollQuiet landing buffer), reader-initiated loading only (the top sentinel, walk poll and idle prefetch all authorize through shouldAutoFillOlder: real input this session, or a transcript too short to scroll), and MeasureFarm measured-only landings. Scroll stability: height-anchor age gate scoped to the viewport- relative path only (the bottom re-pin reads live geometry and now runs regardless of anchor age); native-anchoring remainder writes; commit- phase prepend mirror (concurrent-render tear); 'ready'-only anchor consumption; display-key rename migration in the height cache; headless boundary-turn tail keying; WarmSwap frozen-height remounts; sticky shell activity line (the per-tool collapse ease was the tool-rhythm bounce); latched running flag for the display layer. TRIGGER 1 consolidation with the landed #8001 (0201f91): anchor identity prefers getStableId (the row's tail, which survives regroups, so the miss rarely happens); on a miss, the landed positional re-identification (nearest-survivor displacement, net count when none) re-anchors and feeds the same DOM-measured part-1/part-2 path -- which is inherently immune to double compensation against native scroll anchoring; only when even that finds nothing does the arithmetic fallback compensate (insertedPx minus the native adjustment). Part 1 re-bases by the anchored row's DISPLACEMENT (signed, clamped) rather than the net count, and carries the net separately for the fallbacks. The equal-count swap trigger's render-phase bump is latched per commit: its termination argument assumed a render-phase mirror advance, and the mirror now advances at commit. The legacy status-line collapse test is replaced by the freeze contract the sticky rework establishes. The wholesale-retirement tests pin the positional path's real invariants: the visual anchor (successor row at the viewport top) and the no-double-compensation upper bound -- not an exact scrollTop, which estimate-vs-real spacer pricing legitimately skews until rows re-measure. Review-lane fixes folded in: the unconsumed estimateItem/estimateAt per-row pricing option is removed (dead rider); mint-pod-url validates its argv, resolves the binary first, and pins its suppression to the finding line; the two flagged scroll rigs share one boot preamble via rig-lib.mjs; deliberate effect-dependency omissions carry the repo's standard disable-with-reason spelling (zero warnings under the burned-to-zero ceiling); test doubles drop `any` casts and unused imports; PR-body screenshot evidence lives under temp-screenshots/chat-history-polish/.
Squashed rebase of the chat-history-polish series onto main after upstream #8001 landed in the same TRIGGER 1 site. Archived history: full-chain pagination (backend page endpoints, walk rigs, scrollQuiet landing buffer), reader-initiated loading only (the top sentinel, walk poll and idle prefetch all authorize through shouldAutoFillOlder: real input this session, or a transcript too short to scroll), and MeasureFarm measured-only landings. Scroll stability: height-anchor age gate scoped to the viewport- relative path only (the bottom re-pin reads live geometry and now runs regardless of anchor age); native-anchoring remainder writes; commit- phase prepend mirror (concurrent-render tear); 'ready'-only anchor consumption; display-key rename migration in the height cache; headless boundary-turn tail keying; WarmSwap frozen-height remounts; sticky shell activity line (the per-tool collapse ease was the tool-rhythm bounce); latched running flag for the display layer. TRIGGER 1 consolidation with the landed #8001 (0201f91): anchor identity prefers getStableId (the row's tail, which survives regroups, so the miss rarely happens); on a miss, the landed positional re-identification (nearest-survivor displacement, net count when none) re-anchors and feeds the same DOM-measured part-1/part-2 path -- which is inherently immune to double compensation against native scroll anchoring; only when even that finds nothing does the arithmetic fallback compensate (insertedPx minus the native adjustment). Part 1 re-bases by the anchored row's DISPLACEMENT (signed, clamped) rather than the net count, and carries the net separately for the fallbacks. The equal-count swap trigger's render-phase bump is latched per commit: its termination argument assumed a render-phase mirror advance, and the mirror now advances at commit. The legacy status-line collapse test is replaced by the freeze contract the sticky rework establishes. The wholesale-retirement tests pin the positional path's real invariants: the visual anchor (successor row at the viewport top) and the no-double-compensation upper bound -- not an exact scrollTop, which estimate-vs-real spacer pricing legitimately skews until rows re-measure. Review-lane fixes folded in: the unconsumed estimateItem/estimateAt per-row pricing option is removed (dead rider); mint-pod-url validates its argv, resolves the binary first, and suppresses the taint rule by its full doubled id on the finding line; the two flagged scroll rigs share one boot preamble via rig-lib.mjs; deliberate effect-dependency omissions carry the repo's standard disable-with-reason spelling (zero warnings under the burned-to-zero ceiling); test doubles drop `any` casts and unused imports; non-copy literals this branch adds are shape-excluded with measured rationale (DOMException protocol names, useStagedMount remount cache keys, the heightScopeKey partition key); PR-body screenshot evidence lives under temp-screenshots/chat-history-polish/.
Squashed rebase of the chat-history-polish series onto main after upstream #8001 landed in the same TRIGGER 1 site. Archived history: full-chain pagination (backend page endpoints, walk rigs, scrollQuiet landing buffer), reader-initiated loading only (the top sentinel, walk poll and idle prefetch all authorize through shouldAutoFillOlder: real input this session, or a transcript too short to scroll), and MeasureFarm measured-only landings. Scroll stability: height-anchor age gate scoped to the viewport- relative path only (the bottom re-pin reads live geometry and now runs regardless of anchor age); native-anchoring remainder writes; commit- phase prepend mirror (concurrent-render tear); 'ready'-only anchor consumption; display-key rename migration in the height cache; headless boundary-turn tail keying; WarmSwap frozen-height remounts; sticky shell activity line (the per-tool collapse ease was the tool-rhythm bounce); latched running flag for the display layer. TRIGGER 1 consolidation with the landed #8001 (0201f91): anchor identity prefers getStableId (the row's tail, which survives regroups, so the miss rarely happens); on a miss, the landed positional re-identification (nearest-survivor displacement, net count when none) re-anchors and feeds the same DOM-measured part-1/part-2 path -- which is inherently immune to double compensation against native scroll anchoring; only when even that finds nothing does the arithmetic fallback compensate (insertedPx minus the native adjustment). Part 1 re-bases by the anchored row's DISPLACEMENT (signed, clamped) rather than the net count, and carries the net separately for the fallbacks. The equal-count swap trigger's render-phase bump is latched per commit: its termination argument assumed a render-phase mirror advance, and the mirror now advances at commit. The legacy status-line collapse test is replaced by the freeze contract the sticky rework establishes. The wholesale-retirement tests pin the positional path's real invariants: the visual anchor (successor row at the viewport top) and the no-double-compensation upper bound -- not an exact scrollTop, which estimate-vs-real spacer pricing legitimately skews until rows re-measure. Review-lane fixes folded in: the unconsumed estimateItem/estimateAt per-row pricing option is removed (dead rider); mint-pod-url validates its argv, resolves the binary first, and suppresses the taint rule by its full doubled id on the finding line; the two flagged scroll rigs share one boot preamble via rig-lib.mjs; deliberate effect-dependency omissions carry the repo's standard disable-with-reason spelling (zero warnings under the burned-to-zero ceiling); test doubles drop `any` casts and unused imports; non-copy literals this branch adds are shape-excluded with measured rationale (DOMException protocol names, useStagedMount remount cache keys, the heightScopeKey partition key); PR-body screenshot evidence lives under temp-screenshots/chat-history-polish/.
Squashed rebase of the chat-history-polish series onto main after upstream #8001 landed in the same TRIGGER 1 site. Archived history: full-chain pagination (backend page endpoints, walk rigs, scrollQuiet landing buffer), reader-initiated loading only (the top sentinel, walk poll and idle prefetch all authorize through shouldAutoFillOlder: real input this session, or a transcript too short to scroll), and MeasureFarm measured-only landings. Scroll stability: height-anchor age gate scoped to the viewport- relative path only (the bottom re-pin reads live geometry and now runs regardless of anchor age); native-anchoring remainder writes; commit- phase prepend mirror (concurrent-render tear); 'ready'-only anchor consumption; display-key rename migration in the height cache; headless boundary-turn tail keying; WarmSwap frozen-height remounts; sticky shell activity line (the per-tool collapse ease was the tool-rhythm bounce); latched running flag for the display layer. TRIGGER 1 consolidation with the landed #8001 (0201f91): anchor identity prefers getStableId (the row's tail, which survives regroups, so the miss rarely happens); on a miss, the landed positional re-identification (nearest-survivor displacement, net count when none) re-anchors and feeds the same DOM-measured part-1/part-2 path -- which is inherently immune to double compensation against native scroll anchoring; only when even that finds nothing does the arithmetic fallback compensate (insertedPx minus the native adjustment). Part 1 re-bases by the anchored row's DISPLACEMENT (signed, clamped) rather than the net count, and carries the net separately for the fallbacks. The equal-count swap trigger's render-phase bump is latched per commit: its termination argument assumed a render-phase mirror advance, and the mirror now advances at commit. The legacy status-line collapse test is replaced by the freeze contract the sticky rework establishes. The wholesale-retirement tests pin the positional path's real invariants: the visual anchor (successor row at the viewport top) and the no-double-compensation upper bound -- not an exact scrollTop, which estimate-vs-real spacer pricing legitimately skews until rows re-measure. Review-lane fixes folded in: the unconsumed estimateItem/estimateAt per-row pricing option is removed (dead rider); mint-pod-url validates its argv, resolves the binary first, and suppresses the taint rule by its full doubled id on the finding line; the two flagged scroll rigs share one boot preamble via rig-lib.mjs; deliberate effect-dependency omissions carry the repo's standard disable-with-reason spelling (zero warnings under the burned-to-zero ceiling); test doubles drop `any` casts and unused imports; non-copy literals this branch adds are shape-excluded with measured rationale (DOMException protocol names, useStagedMount remount cache keys, the heightScopeKey partition key); PR-body screenshot evidence lives under temp-screenshots/chat-history-polish/.
Squashed rebase of the chat-history-polish series onto main after upstream #8001 landed in the same TRIGGER 1 site. Archived history: full-chain pagination (backend page endpoints, walk rigs, scrollQuiet landing buffer), reader-initiated loading only (the top sentinel, walk poll and idle prefetch all authorize through shouldAutoFillOlder: real input this session, or a transcript too short to scroll), and MeasureFarm measured-only landings. Scroll stability: height-anchor age gate scoped to the viewport- relative path only (the bottom re-pin reads live geometry and now runs regardless of anchor age); native-anchoring remainder writes; commit- phase prepend mirror (concurrent-render tear); 'ready'-only anchor consumption; display-key rename migration in the height cache; headless boundary-turn tail keying; WarmSwap frozen-height remounts; sticky shell activity line (the per-tool collapse ease was the tool-rhythm bounce); latched running flag for the display layer. TRIGGER 1 consolidation with the landed #8001 (0201f91): anchor identity prefers getStableId (the row's tail, which survives regroups, so the miss rarely happens); on a miss, the landed positional re-identification (nearest-survivor displacement, net count when none) re-anchors and feeds the same DOM-measured part-1/part-2 path -- which is inherently immune to double compensation against native scroll anchoring; only when even that finds nothing does the arithmetic fallback compensate (insertedPx minus the native adjustment). Part 1 re-bases by the anchored row's DISPLACEMENT (signed, clamped) rather than the net count, and carries the net separately for the fallbacks. The equal-count swap trigger's render-phase bump is latched per commit: its termination argument assumed a render-phase mirror advance, and the mirror now advances at commit. The legacy status-line collapse test is replaced by the freeze contract the sticky rework establishes. The wholesale-retirement tests pin the positional path's real invariants: the visual anchor (successor row at the viewport top) and the no-double-compensation upper bound -- not an exact scrollTop, which estimate-vs-real spacer pricing legitimately skews until rows re-measure. Review-lane fixes folded in: the unconsumed estimateItem/estimateAt per-row pricing option is removed (dead rider); mint-pod-url validates its argv, resolves the binary first, and suppresses the taint rule by its full doubled id on the finding line; the two flagged scroll rigs share one boot preamble via rig-lib.mjs; deliberate effect-dependency omissions carry the repo's standard disable-with-reason spelling (zero warnings under the burned-to-zero ceiling); test doubles drop `any` casts and unused imports; non-copy literals this branch adds are shape-excluded with measured rationale (DOMException protocol names, useStagedMount remount cache keys, the heightScopeKey partition key); PR-body screenshot evidence lives under temp-screenshots/chat-history-polish/.
Squashed rebase of the chat-history-polish series onto main after upstream #8001 landed in the same TRIGGER 1 site. Archived history: full-chain pagination (backend page endpoints, walk rigs, scrollQuiet landing buffer), reader-initiated loading only (the top sentinel, walk poll and idle prefetch all authorize through shouldAutoFillOlder: real input this session, or a transcript too short to scroll), and MeasureFarm measured-only landings. Scroll stability: height-anchor age gate scoped to the viewport- relative path only (the bottom re-pin reads live geometry and now runs regardless of anchor age); native-anchoring remainder writes; commit- phase prepend mirror (concurrent-render tear); 'ready'-only anchor consumption; display-key rename migration in the height cache; headless boundary-turn tail keying; WarmSwap frozen-height remounts; sticky shell activity line (the per-tool collapse ease was the tool-rhythm bounce); latched running flag for the display layer. TRIGGER 1 consolidation with the landed #8001 (0201f91): anchor identity prefers getStableId (the row's tail, which survives regroups, so the miss rarely happens); on a miss, the landed positional re-identification (nearest-survivor displacement, net count when none) re-anchors and feeds the same DOM-measured part-1/part-2 path -- which is inherently immune to double compensation against native scroll anchoring; only when even that finds nothing does the arithmetic fallback compensate (insertedPx minus the native adjustment). Part 1 re-bases by the anchored row's DISPLACEMENT (signed, clamped) rather than the net count, and carries the net separately for the fallbacks. The equal-count swap trigger's render-phase bump is latched per commit: its termination argument assumed a render-phase mirror advance, and the mirror now advances at commit. The legacy status-line collapse test is replaced by the freeze contract the sticky rework establishes. The wholesale-retirement tests pin the positional path's real invariants: the visual anchor (successor row at the viewport top) and the no-double-compensation upper bound -- not an exact scrollTop, which estimate-vs-real spacer pricing legitimately skews until rows re-measure. Review-lane fixes folded in: the unconsumed estimateItem/estimateAt per-row pricing option is removed (dead rider); mint-pod-url validates its argv, resolves the binary first, and suppresses the taint rule by its full doubled id on the finding line; the two flagged scroll rigs share one boot preamble via rig-lib.mjs; deliberate effect-dependency omissions carry the repo's standard disable-with-reason spelling (zero warnings under the burned-to-zero ceiling); test doubles drop `any` casts and unused imports; non-copy literals this branch adds are shape-excluded with measured rationale (DOMException protocol names, useStagedMount remount cache keys, the heightScopeKey partition key); PR-body screenshot evidence lives under temp-screenshots/chat-history-polish/.
Squashed rebase of the chat-history-polish series onto main after upstream #8001 landed in the same TRIGGER 1 site. Archived history: full-chain pagination (backend page endpoints, walk rigs, scrollQuiet landing buffer), reader-initiated loading only (the top sentinel, walk poll and idle prefetch all authorize through shouldAutoFillOlder: real input this session, or a transcript too short to scroll), and MeasureFarm measured-only landings. Scroll stability: height-anchor age gate scoped to the viewport- relative path only (the bottom re-pin reads live geometry and now runs regardless of anchor age); native-anchoring remainder writes; commit- phase prepend mirror (concurrent-render tear); 'ready'-only anchor consumption; display-key rename migration in the height cache; headless boundary-turn tail keying; WarmSwap frozen-height remounts; sticky shell activity line (the per-tool collapse ease was the tool-rhythm bounce); latched running flag for the display layer. TRIGGER 1 consolidation with the landed #8001 (0201f91): anchor identity prefers getStableId (the row's tail, which survives regroups, so the miss rarely happens); on a miss, the landed positional re-identification (nearest-survivor displacement, net count when none) re-anchors and feeds the same DOM-measured part-1/part-2 path -- which is inherently immune to double compensation against native scroll anchoring; only when even that finds nothing does the arithmetic fallback compensate (insertedPx minus the native adjustment). Part 1 re-bases by the anchored row's DISPLACEMENT (signed, clamped) rather than the net count, and carries the net separately for the fallbacks. The equal-count swap trigger's render-phase bump is latched per commit: its termination argument assumed a render-phase mirror advance, and the mirror now advances at commit. The legacy status-line collapse test is replaced by the freeze contract the sticky rework establishes. The wholesale-retirement tests pin the positional path's real invariants: the visual anchor (successor row at the viewport top) and the no-double-compensation upper bound -- not an exact scrollTop, which estimate-vs-real spacer pricing legitimately skews until rows re-measure. Review-lane fixes folded in: the unconsumed estimateItem/estimateAt per-row pricing option is removed (dead rider); mint-pod-url validates its argv, resolves the binary first, and suppresses the taint rule by its full doubled id on the finding line; the two flagged scroll rigs share one boot preamble via rig-lib.mjs; deliberate effect-dependency omissions carry the repo's standard disable-with-reason spelling (zero warnings under the burned-to-zero ceiling); test doubles drop `any` casts and unused imports; non-copy literals this branch adds are shape-excluded with measured rationale (DOMException protocol names, useStagedMount remount cache keys, the heightScopeKey partition key); PR-body screenshot evidence lives under temp-screenshots/chat-history-polish/.
Squashed rebase of the chat-history-polish series onto main after upstream #8001 landed in the same TRIGGER 1 site. Archived history: full-chain pagination (backend page endpoints, walk rigs, scrollQuiet landing buffer), reader-initiated loading only (the top sentinel, walk poll and idle prefetch all authorize through shouldAutoFillOlder: real input this session, or a transcript too short to scroll), and MeasureFarm measured-only landings. Scroll stability: height-anchor age gate scoped to the viewport- relative path only (the bottom re-pin reads live geometry and now runs regardless of anchor age); native-anchoring remainder writes; commit- phase prepend mirror (concurrent-render tear); 'ready'-only anchor consumption; display-key rename migration in the height cache; headless boundary-turn tail keying; WarmSwap frozen-height remounts; sticky shell activity line (the per-tool collapse ease was the tool-rhythm bounce); latched running flag for the display layer. TRIGGER 1 consolidation with the landed #8001 (0201f91): anchor identity prefers getStableId (the row's tail, which survives regroups, so the miss rarely happens); on a miss, the landed positional re-identification (nearest-survivor displacement, net count when none) re-anchors and feeds the same DOM-measured part-1/part-2 path -- which is inherently immune to double compensation against native scroll anchoring; only when even that finds nothing does the arithmetic fallback compensate (insertedPx minus the native adjustment). Part 1 re-bases by the anchored row's DISPLACEMENT (signed, clamped) rather than the net count, and carries the net separately for the fallbacks. The equal-count swap trigger's render-phase bump is latched per commit: its termination argument assumed a render-phase mirror advance, and the mirror now advances at commit. The legacy status-line collapse test is replaced by the freeze contract the sticky rework establishes. The wholesale-retirement tests pin the positional path's real invariants: the visual anchor (successor row at the viewport top) and the no-double-compensation upper bound -- not an exact scrollTop, which estimate-vs-real spacer pricing legitimately skews until rows re-measure. Review-lane fixes folded in: the unconsumed estimateItem/estimateAt per-row pricing option is removed (dead rider); mint-pod-url validates its argv, resolves the binary first, and suppresses the taint rule by its full doubled id on the finding line; the two flagged scroll rigs share one boot preamble via rig-lib.mjs; deliberate effect-dependency omissions carry the repo's standard disable-with-reason spelling (zero warnings under the burned-to-zero ceiling); test doubles drop `any` casts and unused imports; non-copy literals this branch adds are shape-excluded with measured rationale (DOMException protocol names, useStagedMount remount cache keys, the heightScopeKey partition key); PR-body screenshot evidence lives under temp-screenshots/chat-history-polish/.
Squashed rebase of the chat-history-polish series onto main after upstream #8001 landed in the same TRIGGER 1 site. Archived history: full-chain pagination (backend page endpoints, walk rigs, scrollQuiet landing buffer), reader-initiated loading only (the top sentinel, walk poll and idle prefetch all authorize through shouldAutoFillOlder: real input this session, or a transcript too short to scroll), and MeasureFarm measured-only landings. Scroll stability: height-anchor age gate scoped to the viewport- relative path only (the bottom re-pin reads live geometry and now runs regardless of anchor age); native-anchoring remainder writes; commit- phase prepend mirror (concurrent-render tear); 'ready'-only anchor consumption; display-key rename migration in the height cache; headless boundary-turn tail keying; WarmSwap frozen-height remounts; sticky shell activity line (the per-tool collapse ease was the tool-rhythm bounce); latched running flag for the display layer. TRIGGER 1 consolidation with the landed #8001 (0201f91): anchor identity prefers getStableId (the row's tail, which survives regroups, so the miss rarely happens); on a miss, the landed positional re-identification (nearest-survivor displacement, net count when none) re-anchors and feeds the same DOM-measured part-1/part-2 path -- which is inherently immune to double compensation against native scroll anchoring; only when even that finds nothing does the arithmetic fallback compensate (insertedPx minus the native adjustment). Part 1 re-bases by the anchored row's DISPLACEMENT (signed, clamped) rather than the net count, and carries the net separately for the fallbacks. The equal-count swap trigger's render-phase bump is latched per commit: its termination argument assumed a render-phase mirror advance, and the mirror now advances at commit. The legacy status-line collapse test is replaced by the freeze contract the sticky rework establishes. The wholesale-retirement tests pin the positional path's real invariants: the visual anchor (successor row at the viewport top) and the no-double-compensation upper bound -- not an exact scrollTop, which estimate-vs-real spacer pricing legitimately skews until rows re-measure. Review-lane fixes folded in: the unconsumed estimateItem/estimateAt per-row pricing option is removed (dead rider); mint-pod-url validates its argv, resolves the binary first, and suppresses the taint rule by its full doubled id on the finding line; the two flagged scroll rigs share one boot preamble via rig-lib.mjs; deliberate effect-dependency omissions carry the repo's standard disable-with-reason spelling (zero warnings under the burned-to-zero ceiling); test doubles drop `any` casts and unused imports; non-copy literals this branch adds are shape-excluded with measured rationale (DOMException protocol names, useStagedMount remount cache keys, the heightScopeKey partition key); PR-body screenshot evidence lives under temp-screenshots/chat-history-polish/.
Problem / Motivation
Two power users report that, in a long session, the transcript "suddenly jumps back to the start of the session" with no scroll input, and they then have to click the jump-to-bottom arrow to get back to the latest message. The jump happens around the end of a turn.
Why it matters
It hits exactly the people who use the dashboard the most: only a transcript longer than the entry page can grow in front, so short sessions never see it, while an all-day session pays it at turn boundaries. Losing the reading position to the very beginning of a transcript is the most disorienting form of a scroll defect, and it is what makes the surrounding follow/flicker behaviour visible in the first place (the user has to re-engage follow by hand).
What changed (motivation → approach → change)
Symptom → root cause.
useVirtualChat's prepend trigger (TRIGGER 1) compensates a front growth by anchoring on the topmost visible row whose key survives into the new list. When a wholesale transcript rebuild lands together with the growth — the post-turnrefreshSlotreplacing a 100-row entry page with the whole transcript while every streamed row is re-identified by its server copy — no visible key survives.captureTopAnchorFromreturnsnull, the capture stands down, and neither the window norscrollTopmoves while hundreds of rows materialise in front. The old index range, unchanged, is now the start of the transcript.Reproduced in the existing prepend harness: a 30-row tail behind 1000 new rows, follow released, every tail key changed — the reader landed on row 27 of 1030 with
scrollTopuntouched.Change. When no visible key survives, the topmost visible row is re-found by position and handed to the same Part 1 re-base and the same Part 2 correction the surviving-key case uses — no new path, no new consumer, no new invalidation key.
The displacement is not the net count growth. The net count equals a row's displacement only for a pure front insert; a rebuild that also grows the tail (a reconnect catching up on missed rows) would overshoot the reader by the tail growth — a shape where a naive positional anchor is worse than the old stand-down. So the displacement is read off the nearest row (by old index) whose key did survive; only when no key survives anywhere does the net count stand in, which keeps the reader's distance from the end. Both paths now re-base by that same measure — the anchored row's own displacement, signed — instead of the net count. Rows coalescing above the reader while the tail grows move them up although the count grew; a net-count re-base carried the window the wrong way past the anchor and left a blank band. Two guards make the signed re-base safe: TRIGGER 2 yields while a re-base is in flight (a negative re-base moves the range up, which TRIGGER 2 reads as a window shift and would otherwise overwrite the pending anchor), and Part 1 clears the slot on every exit from
'awaiting-rebase'so no anchor can be stranded there.website/src/hooks/virtualizer/useVirtualChat.ts— TRIGGER 1: key→index map replaces the survivor set; positional fallback with nearest-survivor displacement (binary search over survivors in old-index order); signed re-base by displacement on both paths; TRIGGER 2 yields during a re-base; Part 1 stage-sentinel + unconditional clear.captureTopAnchorFromalso returns the captured row's index; the slot-switch anchor persists{key, top}only.Tests
useVirtualChat.prependAnchor.test.tsxholds the reader by POSITION when a front growth retires every visible key: 1000 rows in front, every tail key changed; asserts new indexold + 1000, the positional successor key, unchanged screen offset (±1px),scrollTopadvanced.... when getKey is INDEX-ADDRESSED(ChatPage'srowKeys[i]shape): distinguishes pairing the new row with the current getKey from mispairing it with the previous render's — the latter yields a key no mounted row carries and leaves the viewport in spacer.moves the positional anchor by the nearest SURVIVOR's displacement, not the net count growth: front +10, tail +5, visible keys retired, rows above the viewport keep theirs; asserts the reader moved by 10.re-bases by the anchored row's own displacement when a surviving key is found: front +3, tail +50 with a surviving visible key; a net-count re-base would push the window 50 rows past the anchor.holds the reader when rows ABOVE them coalesce while the tail grows (negative displacement): 3 rows above collapse to 1, tail +10 (count +8, displacement −2); asserts the row's screen offset is held and no blank band.shows no blank band when a prepend retires EVERY visible key: previously pinned the stand-down as an accepted loss of position; now pins that the correction runs and a mounted row still covers the viewport top.prependPrev.getKey;inserted - 1; displacement replaced by the net count on the fallback path; displacement replaced by the net count on the surviving-key path; positive-only arming gate restored; TRIGGER 2's in-flight yield removed.vitest run: 27889 passed.tsc -b,eslint src/ --max-warnings 597,jscpdclean.Manual verification
Not reproduced in a live browser in this PR: the harness reproduces the mechanism (front growth + total key retirement with follow released), not the specific rebuild that produced it on the reporters' machines. Reporters are on
v0.5.0-insider.9, whose virtualizer predates this and the other recent scroll fixes; a build carryingmainis what lets them confirm.Related Issues
Related: #7045 (bubble vanish / reconciliation), #4394 (same-tick row key aliasing — a key-retirement source this fallback tolerates).
Pattern harvest
Rule candidate: review-prompt
Pattern: a compensation path whose "anchor not found" branch is a silent no-op — on a list whose front grows, doing nothing is never neutral; fall back to a positional anchor or fail loudly.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)