React AI Long-Conversation Virtualization: Dynamic Heights, Scroll Anchoring, and CLS Gates
AI messages include code, citations, and delayed content, so ordinary fixed-height virtual lists jump when history is backfilled. This article uses stable message keys, height correction, explicit anchors, and real-browser fixtures to constrain DOM growth and scroll drift.
Key Points
- Topic Focus: AI、React、TypeScript
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
AI conversations are not ordinary fixed-height lists: responses grow during streaming, lines wrap after code highlighting, citation panels expand, and images and fonts may arrive late. Calculating the window from average height alone pushes away the paragraph the user is reading when earlier history loads; directly rendering all records makes the DOM, style calculation, and accessibility tree grow without bound as the conversation grows. React Virtualization introduces windowing principles; this article addresses only dynamic-height and anchor invariants.
The Acceptance Fixture Must Include Real Height Changes
Generate a fixed set of 10,000 messages with immutable messageId values, mixing single-line text, 20-line code blocks, tables, collapsed citations, and images with intrinsic dimensions. Initially open the latest 80 messages, scroll upward to message 5,000, then prepend 100 historical messages; at the same time, let a code block in the viewport finish highlighting, have two images decode 800 ms late, and grow the active response at the bottom another 200 times. The approval matrix records React 19.2.4, the full browser version, zoom level, font digest, viewport, and device pixel ratio.
Release gates require: no more than 220 message DOM nodes at any time; anchor top-coordinate drift before and after prepend has P95 no greater than 2 CSS px and a maximum no greater than 8 px; when the user is more than 48 px from the bottom, new tokens cause 0 automatic scrolls; while at the bottom, the view remains within a 2 px bottom tolerance after every paint; cumulative layout-shift without recent input is below 0.02; and ResizeObserver causes at most one state commit per frame. Compare memory only against stable heap baselines under the same build and browser, requiring retained memory after ten back-and-forth scrolls to grow by no more than 10%; do not treat one garbage-collection moment as a conclusion.
CLS is a viewport-level signal and cannot independently prove that a scroll container is stable. The test also records the target message’s getBoundingClientRect().top, scrollTop, window range, and layout-shift entries. Web Vitals Field and Lab Data can explain differences between the two environments, but anchor drift must be calculated per commit with this deterministic fixture.
Cache Heights by Message Version
The estimator assigns an initial height by message type, while ResizeObserver measures the border box after the actual element mounts. The cache key is messageId + renderRevision + widthBucket + fontRevision, never an array index; when Markdown finishes, a citation expands, the container crosses a width bucket, or a font changes, invalidate only the affected keys. When native scroll anchoring is disabled, after layout and before paint the Observer uses the saved anchor and height delta to correct scrollTop directly, so late images or fonts do not first paint a jump; the React height model and window range are still updated in a batch on the next frame. The callback must not force layout again after a write, nor repeatedly call setState in the same pass; if the implementation cannot guarantee this ordering, retain browser-native anchoring for late resources instead of disabling both compensation systems.
A streaming active message can limit height correction to once per frame. Historical messages outside the window must not receive new references because the current response grows, and outer list state must not rebuild all item data. The stable keys in React Performance Optimization are a correctness condition here: index keys bind height, focus, and component-local state to the wrong messages after prepend.
Explicit Anchors Cover Three Scroll Intentions
Before every structural change, choose an anchor: when the user is within bottom tolerance, anchor the end of the list; when they are reading history, choose the first fully visible message and save its ID and top offset; when programmatically jumping to a search result, anchor the target ID. After the commit, find the same message during the layout-effect phase and correct scrollTop by the difference between its old and new top positions. Only one system may compensate for a given update: if the virtualizer uses manual anchoring, set controlled overflow-anchor: none on the scroll container to prevent browser-native Scroll Anchoring from compensating again.
If the anchor message is deleted or becomes invisible after window recalculation, fall back by adjacent stable ID rather than guessing from the old array index. Compensate only when a height change occurs above the anchor; when it occurs inside the anchor, keep its top fixed and allow content below to grow naturally. Freeze automatic stick-to-bottom behavior when the user actively scrolls with the wheel, touch, or scrollbar, until they explicitly return to the bottom. If focus is in a control about to leave the window, first move focus to a semantic conversation container and provide an action to return to the original message; do not make keyboard focus disappear merely to save DOM. The focus principles in React Accessibility Components apply here as well.
From Pixel Failure Drills to Reversible Release
With fixed fonts and numerical assertions in addition to screenshots, Playwright replays: five consecutive pages of prepend, expanding a citation above the anchor, replacing a font after loading fails, images without dimensions, portrait-to-landscape container changes, message deletion, search jumps, and late arrival of an old stream. Every animation frame saves the anchor ID, top offset, height-cache hits, DOM count, and observer batch count, checking both the final stable coordinates and preventing an intermediate frame from jumping before compensation. Screenshots can help locate issues, but cannot replace the 2 px contract.
The rollout first applies only to read-only histories longer than 1,000 messages, while active responses remain on the old list; then enable it for 1% of long conversations and observe anchor drift, erroneous bottom jumps, and error rates by browser group. The rollback target is the paginated window, not rerendering 10,000 messages: the feature flag stops virtualizer measurement, retains the current anchor ID, loads the previous-version page containing that ID, and restores the offset. Roll back if any browser exceeds 8 px maximum drift, focus is lost, the DOM exceeds its bound, or retained memory grows continuously.
Further Reading
Related
Related Content
Article
What Is Vibe Coding? Will It Change How Programmers Work?
Technical trends, engineering practices, and an indie developer perspective on Vibe Coding, AI, and independent development.
Article
From Copilot to Codex: AI Coding Tools Enter the Agent Era
Technical trends, engineering practices, and indie-developer perspectives around AI, AI Agents, and Vibe Coding.
Article
Complete Technology Stack Analysis for LLM Application Development
Covers tech trends, engineering practices, and indie developer perspectives around large models, RAG, and AI.
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.