fix(virtualizer): hold the reader across an equal-count row swap - #7811
Conversation
The render-phase scroll-anchor capture has ONE consumer, invalidated by `windowRange` and `itemCount`. An equal-count commit moves neither, so the shape React batches on every streaming turn -- the thinking placeholder leaving and its replacement arriving in one update -- had no trigger and no correction. A replacement taller than the row it replaces moved the row a reader scrolled up was looking at: 700px in the prependAnchor harness with a 3x-taller replacement (200px of real row growth, the rest the mean re-pricing every unmeasured row above them). TRIGGER 6 now captures through the same point and the same consumer as the mid-list splice, and brings its own invalidation key: a `spliceCommit` counter bumped in the render that captures a swap anchor. That key is what makes the capture safe rather than merely possible -- the anchor is spent in the very commit it describes instead of sitting in the slot for an unrelated later commit to spend, which is the stranded-anchor hazard the render-phase capture exists to remove. Real state rather than a ref token, for the reason `heightCommit` is: a counter the effect does not subscribe to is invisible to tooling and needs an exhaustive-deps exemption to sit in the dep array at all. The bump is the render-time state-update pattern the session and cache sentinels already use, and terminates because `prependPrevRef` has advanced by the time React re-invokes the render. It sits INSIDE the capture, which is gated on stick, so a pinned reader never pays the extra render pass, and a token append is not a swap and never reaches it. Closes #7234
UX Review (Fable 5) — ✅ PASSUX-level review of The diff is entirely internal virtualizer logic ( UX-Verdict: PASS No user-facing surface changes; the fix removes a scroll jump mid-read and preserves bottom-pinned following, verified for both directions in tests. [UX-REVIEWED] 3e2f516 |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS The missing invalidation key was the actual root cause; the fix lands it at the contract, keeps one anchor path, and proves both halves load-bearing independently. [DESIGN-REVIEWED] 3e2f516 |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All claims verified against the repo: First-Principles-Verdict: PASS A reported, measured 700px reader drift (#7234) is removed at the invalidation contract — the actual cause — through the existing capture point and consumer, adding no parallel path. What this change shipsIntent: keep a scrolled-up reader's row steady when the thinking placeholder is swapped for taller output in one commit — a FIX.
No riders, no undeclared items, no new public surface: the hook's signature and every config/API boundary are unchanged, and the fix reuses the existing capture point, anchor slot, and consumer rather than adding a second correction path. [FIRST-PRINCIPLES-REVIEWED] 3e2f516 |
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: |
NicholasRBowers
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 clear root cause — equal-count row swap stranded the scroll anchor because no invalidation key moved; adds a spliceCommit key so the anchor is consumed in its own commit, with a red-before regression test.
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: frontend-only virtualizer scroll-anchor fix with a clear root cause — an equal-count row swap moves neither invalidation key the compensation consumer watches, so the reader lost position; the fix adds a dedicated spliceCommit key so the anchor is spent in the commit it describes, plus four tests including the pinned-reader negative case.
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: adds a sixth compensation trigger with its own spliceCommit invalidation key so the virtual-chat reader anchor survives an equal-count row swap; frontend virtualizer hook plus its test, no runtime surface beyond scroll anchoring.
Why no screenshot: scroll-position fix with no static visual delta -- the change is where the viewport sits across one commit, which a before/after still cannot show. Reproduced numerically instead (700px of drift in the harness).
Problem / Motivation
The render-phase scroll-anchor capture has one consumer, and it is invalidated by
windowRangeanditemCount. An equal-count commit moves neither, so the consumer does not run in that commit.That left one shape of the transient-row jitter uncompensated after #7198: the "thinking" placeholder leaving and its replacement arriving in one React-batched update. The net count never moves, so no count-delta trigger fires -- and when the replacement renders taller than the row it replaced, everything above a reader scrolled up moves, and their row walks off under them.
Measured in the
prependAnchorharness with a 3x-taller replacement: 700px. Two hundred of that is the row itself growing; the rest is the mean re-pricing every unmeasured row above the reader, becausegetHeightprices an unmeasured row from the running mean of the measured ones, so one new measurement re-prices the whole region.Why it matters
It is the ordinary streaming shape, not an edge case: React batches the placeholder's removal and the output's insertion into a single update, so this fires on every placeholder-to-output transition of every turn. A reader scrolled back through a transcript while an agent is still working gets the #6076 jitter for each one, even with #7198 landed.
What changed (motivation -> approach -> change)
The capture was never the missing piece -- the invalidation key was. #7198 could see the swap (it already retires the departed row's height there) and deliberately declined to capture an anchor: with no key that moves on an equal-count commit, the anchor would have sat in the shared slot until some later commit changed
windowRangeoritemCount, and then been spent on unrelated geometry -- the stranded-anchor hazard the render-phase capture exists to remove. Capturing without the key would have traded a 700px drift for a nondeterministic yank.So the key comes first, and the capture follows it:
1.
spliceCommit, a counter bumped in the render that captures a swap anchor, added to part 2's deps. Every other trigger already rides a key that consumer watches -- a prepend, splice or append movesitemCount, a window shift moveswindowRange. Only the swap has neither, so it brings its own, and the anchor is now spent in the very commit it describes.It is real state rather than a ref token, for the reason
heightCommitin this same file is: a counter the effect does not subscribe to is invisible to tooling and needs anexhaustive-depsexemption to sit in a dep array at all. The bump is the render-time state-update pattern the session and height-cache sentinels above it already use, and it terminates for the same reason they do --prependPrevRefhas advanced by the time React re-invokes the render, so the second pass detects no swap, captures nothing and bumps nothing.2. TRIGGER 6 joins the existing splice gate, through the same capture point, the same slot, the same consumer and the same key mapping (previous items at the mounted node's previous index, filtered to keys that survive the commit, so the replaced row's key falls forward to the next survivor). No parallel anchor path -- a second path would fight this one for
scrollTop.Cost is one extra render pass, and only on a commit that actually captures. The bump sits INSIDE the capture, which is gated on
!stickRef.current, so the primary reading mode (pinned to the bottom) never pays it; and a token append is not a swap, so the commit that lands per streamed chunk never reaches it.What this deliberately does NOT change: the offset memo's deps. The issue proposed adding the key there too, and on main that is already covered by a different mechanism: #7198's retirement block calls
heightIndex.sync(itemCount)in the render phase (useVirtualChat.ts:1050), precisely because the memo is keyed onitemCount. A swap that departs a key -- which is every swap -- therefore already re-syncs the tree in its own commit, and adding the key to the memo would buy a second O(N) walk over the same heights in the same render. Confirmed empirically: the cases below hold at <=1px with the memo untouched.Diff is confined to
website/src/hooks/virtualizer/useVirtualChat.tsand itsprependAnchorharness. No reformatting.Tests
useVirtualChat.prependAnchor.test.tsxgrows a per-key height override (rowHeightByKey), because an equal-height swap displaces nothing and would pass with no fix at all. Three cases, each red before:getKey(ChatPage's shape)spliceCommitdep onlyspliceCommitdep onlyBoth halves are load-bearing, proven separately: reverting only the gate widening (anchor never captured) and reverting only the dep (anchor captured, consumer never runs) each reproduce the full 700px.
Plus a pinned counterpart,
does not hold position for a PINNED reader across an equal-count SWAP, which pins the gate: a pinned reader is never pulled back up to where a row used to sit, and stick survives (the next streamed message still lands at the bottom). Non-vacuity is asserted in each case -- the replaced row really is gone and the taller replacement really mounted at the same index.No existing assertion weakened. The virtualizer family is 223/223 green (
anchorRestore,followDisengage,heightSyncAnchor,integration,layoutShrink,observerBackfill,postStreamLurch,prependAnchor,railCollapse,spacerLurch,viewportResize,zeroHeightGuard,useVirtualChat,UseVirtualChatCoverage,HeightCache,HeightCache.multiInstance,virtualizerHeightOwner,ScrollAnchorCache,ChatPage.queueBandReanchor) -- the family-wide pass the issue asked for. The consumer side is 155/155 green across the files that assert scroll behaviour through this hook (ChatPage.navFarJump,ChatPane.scrollChrome,useChatScrollFollow,useScrollManager,useScrollManagerCov80,FollowController,WindowCalculator,McpAppFrame,ArtifactsPage.singleScroller,ThinkingBlock), which is where an extra render pass would show up if it were visible at all.tsc -b,eslintandjscpdclean.Manual verification
N/A -- the jitter is a scroll-position delta, and the harness reproduces it numerically: its deterministic layout engine makes the rows genuinely move, so every number above is measured rather than asserted against source text.
Related Issues
Closes #7234
Refs #7198, #6076
Pattern harvest
Rule candidate: review-prompt
Pattern: when a shared consumer is invalidated by a proxy for "something changed", any producer whose change the proxy cannot express is silently unserved -- and the fix belongs in the invalidation contract, not in the producer. Here the proxy was a pair of count/range values standing in for "the geometry above the reader moved", and the one commit shape that moves geometry without moving either value had no way to reach the consumer. The tell is a comment explaining that a detection is correct but deliberately not acted on: #7198 identified the swap, retired its height, and had to write down that it could not anchor it. That asymmetry -- half a commit shape handled, the other half documented as out of reach -- is the reviewable signal that an invalidation key, not a trigger, is missing. The general question to ask of any effect whose deps are values rather than events: enumerate the commit shapes that change none of them, and check that none of them is a shape the effect exists to serve.