fix(virtualizer): positional splice anchor on total re-key (#8033) - #8446
Conversation
When a mid-list splice (TRIGGERS 4/5/6) lands in the same commit that retires every visible row's key, the survivingKeys-filtered capture returned null and silently stood down, displacing the reader by the spliced rows' height. Re-find the topmost visible row by POSITION via the nearest-survivor displacement machinery PR 8001 added for TRIGGER 1, factored into a shared helper, and arm the existing splice consumer with the resolved anchor. Closes #8033
UX Review (Fable 5) — ✅ PASSUX-level review of The diff is confined to UX-Verdict: PASS Invisible-chrome fix: the reader's scroll position now holds through splice-plus-re-key commits; no strings, layout, or affordances change. [UX-REVIEWED] 9f5271f |
Design Review (Fable 5) — ✅ PASSDesign-level review of The diff is fully reviewed: a shared Design gate outcome: real harm (reader displacement on total re-key + splice, the deferred half of #8033's class), solved with the approach already proven on the prepend path, factored rather than duplicated, reaching only the previously-silent branch — the common commit path gains no new conditional cost or failure mode, and a still-unresolvable anchor degrades to the pre-existing stand-down. Hook-internal, no contract change, fully reversible. The Design-Verdict: PASS Closes the known-deferred half of an already-fixed defect class by reusing the proven mechanism; rare-path-only, degrades to prior behavior, fully reversible. [DESIGN-REVIEWED] 9f5271f |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of I have everything I need. Let me verify the counts once more against what I read:
First-Principles-Verdict: PASS Closes a reported, previously-deferred defect (#8033) at the cause level, reusing the existing mechanism instead of duplicating it; every item is declared. What this change shipsIntent: a reader scrolled up in history no longer loses their place when a mid-list splice lands in the same commit that re-keys every visible row — a FIX.
Counted claims backing the verdict: 10 [FIRST-PRINCIPLES-REVIEWED] 9f5271f |
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: |
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: |
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: fix with a clear root cause -- useVirtualChat anchored the prepend splice by identity, so a total re-key re-anchored to the wrong row; the fix anchors positionally and adds one regression test. Frontend virtualizer hook only, no auth/session/trust surface.
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 0 + CodeQL 0 alerts), security checklist all-NO, AI reviewers green. Category: fix(virtualizer) positional splice anchor on total re-key (#8033), 2 files, clear root cause.
dwu96
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: fix - restores the splice trigger's no-surviving-key positional fallback (#8033) by extracting the shared nearest-survivor displacement resolver; one hook source file plus its own test, no surface outside chat scroll anchoring.
Problem / Motivation
The mid-list splice capture (TRIGGERS 4/5/6) anchors the topmost visible mounted row whose key survives the commit, filtered through
survivingKeys. When every visible row's key retires in one commit — a total re-identification of the on-screen rows, e.g. each streamed row replaced by its server copy under a different key, landing in the same commit as a splice — the capture returnsnulland silently stands down: no anchor, no scroll compensation, and the reader is displaced by the inserted rows' height.The prepend path (TRIGGER 1) had the identical stand-down; PR #8001 fixed it by re-finding the row by POSITION (nearest-survivor displacement, net count as last resort). The splice half was knowingly deferred there as an accepted-and-deferred First Principles advisory. This PR closes it: the class is the same — a compensation path whose anchor-not-found branch is a silent no-op.
Why it matters
A reader scrolled up through history loses their place whenever a transcript refresh re-identifies the visible rows together with a mid-list insert/removal/swap: the viewport silently jumps by the spliced rows' height. Bounded harm, but it is the last silent no-op branch of this class in the virtualizer.
What changed (motivation → approach → change)
Symptom → root cause: the splice capture's
keyAtfilter admits only surviving keys, so a commit that retires every visible key givescaptureTopAnchorFromnothing to bind and the branch exits without arming the consumer.Approach: reuse TRIGGER 1's positional re-identification rather than duplicating it.
nearestSurvivorShiftFrom(prevItems, prevGetKey, newIndexByKey, noSurvivorShift, boundary). TRIGGER 1's fallback now calls it (behavior unchanged,boundarydefaults to 0 — a prepend renames index 0, so nothing sits before its boundary).null, re-run it with a positionalkeyAt— the row moved by as much as its nearest surviving old-index neighbour, and its new key is whatever now sits at old index + that displacement, priced by the CURRENT render'sgetKeypaired with the current items (the pairing contract PR fix(virtualizer): hold the reader by position when a prepend retires every key #8001 pinned). The resolved anchor arms the existing splice consumer (shiftAnchorRef/shiftStageRefstage'ready', plus therowSwapped→spliceCommitbump), so part 2 performs the ordinary scrollTop correction.boundaryparameter is new relative to PR fix(virtualizer): hold the reader by position when a prepend retires every key #8001's machinery and carries the splice-specific correctness rule (surfaced by the pre-push review): survivors ABOVE the first changed index (movedIndex) did not move, and borrowing their zero displacement would anchor a splice landing exactly at the viewport top on the inserted row itself. Excluding them makes the displacement come from survivors at/after the change; when none remain,noSurvivorShift(the net count change) stands in, which for a contiguous splice above the reader IS the exact displacement.Tests
Four regressions in
website/src/test/useVirtualChat.prependAnchor.test.tsx, all on the existingmountScrolledUpharness, each proving non-vacuity (every previously visible key genuinely gone, ghost genuinely mounted) and that the hold is a realscrollTopwrite:holds the reader by POSITION when a splice retires every visible key— one row inserted above the viewport, index 0's key held, every visible row re-keyed; the topmost visible row keeps its screen offset (without the fix it moves down by the inserted row's height).holds the reader by POSITION across a splice-with-total-retirement when getKey is INDEX-ADDRESSED— pins the current-items/current-getKey pairing contract an identity getKey cannot distinguish.does not anchor the inserted row when the splice lands exactly at the viewport top— the boundary case: rows above the viewport keep their keys but did not move; without the boundary exclusion the anchor resolves to the ghost itself and no correction runs.does not anchor the inserted row at the viewport top when getKey is INDEX-ADDRESSED— same boundary case under the ChatPage-shaped getKey.Manual verification
N/A — the deterministic fake-layout harness reproduces the displacement pixel-for-pixel (the same engine the existing prepend/splice regressions use), and the change is unreachable except on the total-retirement commit it targets.
Related Issues
Closes #8033
Pattern harvest
Not generalizable: this closes the second (and last) known instance of the "compensation path whose anchor-not-found branch is a silent no-op" class that PR #8001 named; both paths now share one positional-fallback helper, so a third instance would reuse it rather than re-derive it.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)