Describe the bug
In the single-lane measurement path, a stable getItemKey callback reading updated data can leave rendered item keys inconsistent with the current message IDs after a same-count update. With anchorTo: 'end', the same update can also lose the reading anchor even with followOnAppend: false.
The reproduction starts with messages 1–20 and Message 9 at the viewport top. Replacing the array with messages 2–21 leaves scrollTop at 400 instead of 350. The rendered range contains 8 key/ID mismatches; for example, Message 10 has VirtualItem.key 9. Message 9 moves 50 px above the viewport top.
Your minimal, reproducible example
Live cache reproduction
Pinned source · Source and local run instructions
The page uses published @tanstack/virtual-core@3.17.9 directly, with the standard element observers and scrolling function. It renders only the virtual range, including overscan, and uses exact 50 px row estimates in a 300 px viewport. Native CSS scroll anchoring is disabled. No network, dynamic row measurements, or custom scroll compensation is involved.
Steps to reproduce
- Open the cache page. Confirm Cached keys different from data IDs: 0,
scrollTop: 400, and Message 9 at the viewport top.
- Click Trim oldest + append once. The array is replaced with messages 2–21, then
setOptions receives the new options. The count and getItemKey function identity stay the same; the callback reads the current array.
- Observe 8 key/ID mismatches and
scrollTop: 400. Message 10 is now at the viewport top; its cached key is 9.
- Click Reset cache example to repeat.
Expected behavior
After the update, VirtualItem.key should match the item represented by its index. The reading anchor should remain Message 9 at the viewport top, which requires scrollTop: 350 after the oldest 50 px row is removed. This expectation does not require following appended output; followOnAppend is explicitly disabled.
How often does this bug happen?
Every time with the steps above.
Platform
- macOS 26.5.2
- Chrome 152 (reported user-agent major version)
- Verified on 2026-09-08 using a production Vite build
tanstack-virtual version
@tanstack/virtual-core@3.17.9
TypeScript version
Not applicable; the reproduction is JavaScript.
Additional context
Proposed fix: #1272.
Related: #1271 describes append-and-trim updates losing end following. This cache reproduction disables following and demonstrates a separate key-consistency failure.
The single-lane layout records positions and sizes immediately, but unread lazy items resolve their keys later through getItemKey. If the data changes between those steps, materialized items retain their old keys while unread items obtain keys from the new array. In this fixture, setOptions reads the old first/last keys lazily after the array has changed, so its edge comparison misses the data update.
The suspected invariant is that a measurement's key should belong to the same layout build as its position and size. The fixture does not read the private measurement cache or pre-read the first/last items to influence change detection.
Terms & Code of Conduct
Describe the bug
In the single-lane measurement path, a stable
getItemKeycallback reading updated data can leave rendered item keys inconsistent with the current message IDs after a same-count update. WithanchorTo: 'end', the same update can also lose the reading anchor even withfollowOnAppend: false.The reproduction starts with messages 1–20 and Message 9 at the viewport top. Replacing the array with messages 2–21 leaves
scrollTopat 400 instead of 350. The rendered range contains 8 key/ID mismatches; for example, Message 10 hasVirtualItem.key9. Message 9 moves 50 px above the viewport top.Your minimal, reproducible example
Live cache reproduction
Pinned source · Source and local run instructions
The page uses published
@tanstack/virtual-core@3.17.9directly, with the standard element observers and scrolling function. It renders only the virtual range, including overscan, and uses exact 50 px row estimates in a 300 px viewport. Native CSS scroll anchoring is disabled. No network, dynamic row measurements, or custom scroll compensation is involved.Steps to reproduce
scrollTop: 400, and Message 9 at the viewport top.setOptionsreceives the new options. The count andgetItemKeyfunction identity stay the same; the callback reads the current array.scrollTop: 400. Message 10 is now at the viewport top; its cached key is 9.Expected behavior
After the update,
VirtualItem.keyshould match the item represented by its index. The reading anchor should remain Message 9 at the viewport top, which requiresscrollTop: 350after the oldest 50 px row is removed. This expectation does not require following appended output;followOnAppendis explicitly disabled.How often does this bug happen?
Every time with the steps above.
Platform
tanstack-virtual version
@tanstack/virtual-core@3.17.9TypeScript version
Not applicable; the reproduction is JavaScript.
Additional context
Proposed fix: #1272.
Related: #1271 describes append-and-trim updates losing end following. This cache reproduction disables following and demonstrates a separate key-consistency failure.
The single-lane layout records positions and sizes immediately, but unread lazy items resolve their keys later through
getItemKey. If the data changes between those steps, materialized items retain their old keys while unread items obtain keys from the new array. In this fixture,setOptionsreads the old first/last keys lazily after the array has changed, so its edge comparison misses the data update.The suspected invariant is that a measurement's key should belong to the same layout build as its position and size. The fixture does not read the private measurement cache or pre-read the first/last items to influence change detection.
Terms & Code of Conduct