fix(app): preserve scroll across history prepends (#44317)

This commit is contained in:
Brendan Allan 2026-08-23 15:00:29 +08:00 committed by GitHub
parent 9692bcbc21
commit 6cd3da1d4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 332 additions and 46 deletions

View file

@ -139,7 +139,7 @@ test.describe("regression: session timeline local row state", () => {
expect(siblingProbe).toEqual({
fileMarker: "before",
frameMarker: "before",
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
rowKey: `assistant-part:part:${assistantMessageID}:${editPartID}`,
rowMarker: "before",
shadowRoots: 0,
toolMarker: "before",

View file

@ -17,7 +17,7 @@ import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const messagePageSize = 200
const messagePageSize = 20
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`

View file

@ -149,9 +149,13 @@ export function createTimelineVirtualizer(input: Input) {
if (listRoot() && input.pinned()) anchorResizedBottom()
}
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
if (!instance.itemSizeCache.has(item.key) && addedKeys.delete(String(item.key))) {
return item.start < (instance.scrollOffset ?? 0) + instance.scrollAdjustments
}
// Prepended rows can resize more than once as deferred content mounts. Keep
// compensating while they remain entirely above the visible content fold.
if (addedKeys.has(String(item.key)))
return (
item.end <=
(instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin
)
const first = instance.range?.startIndex
return first !== undefined && item.index < first
}

View file

@ -55,6 +55,40 @@ test("start anchoring preserves a stable visible item across prepends", () => {
expect(writes.at(-1)).toBe(150)
})
// A pagination boundary can re-key the row at the viewport top when the truncated
// leading turn regroups under its freshly loaded user message. The anchor must fall
// back to the next surviving key instead of leaving the offset on the new content.
test("prepend anchoring survives when the nearest keys are re-keyed", () => {
const root = document.createElement("div")
const writes: number[] = []
const options = (keys: string[]) => ({
count: keys.length,
estimateSize: () => 50,
initialOffset: 50,
initialRect: { width: 400, height: 100 },
anchorTo: "start" as const,
getItemKey: (index: number) => keys[index]!,
getScrollElement: () => root,
scrollToFn: (offset: number) => writes.push(offset),
observeElementRect: () => {},
observeElementOffset: (_element: HTMLDivElement, callback: (offset: number, isScrolling: boolean) => void) => {
callback(50, false)
},
})
// Viewport sits at offset 50: rows "orphan-c" (anchor) and "d" visible.
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>(options(["orphan-c", "d", "e"]))
virtualizer._willUpdate()
virtualizer.getVirtualItems()
// Prepend re-keys the boundary row ("orphan-c" -> "c") while "d" and "e" survive.
virtualizer.setOptions(options(["a", "b", "c", "d", "e"]))
virtualizer._willUpdate()
// "d" was 50px below the anchor at old start 50; restored at new start 150 => offset 150.
expect(virtualizer.getScrollOffset()).toBe(150)
expect(writes.at(-1)).toBe(150)
})
test("reactive count updates preserve measured row sizes", () => {
createRoot((dispose) => {
const [count, setCount] = createSignal(2)

View file

@ -60,6 +60,7 @@ export type CreateDataInput = {
}
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
const messagePageLimit = 20
// Global MCP elicitations temporarily use "global" instead of a real session ID, so the
// server cannot recover their Location when settling them. Preserve the event Location
@ -1318,7 +1319,7 @@ export function createData(config: CreateDataInput) {
},
sync(sessionID: string) {
return sync.run(`session.message:${sessionID}`, async () => {
const response = await api().message.list({ sessionID, limit: 200, order: "desc" })
const response = await api().message.list({ sessionID, limit: messagePageLimit, order: "desc" })
const fetched = response.data.toReversed()
// Same protection as the pending sync: a re-fetch racing an
// admission must not wipe its local transcript row.
@ -1348,7 +1349,7 @@ export function createData(config: CreateDataInput) {
if (!cursor || store.session.messageLoading[sessionID]) return
setStore("session", "messageLoading", sessionID, true)
const response = await api()
.message.list({ sessionID, limit: 200, cursor })
.message.list({ sessionID, limit: messagePageLimit, cursor })
.finally(() => setStore("session", "messageLoading", sessionID, false))
const older = response.data.toReversed()
const existing = store.session.message[sessionID] ?? []

View file

@ -103,6 +103,38 @@ test("reports optimistic sessions as creating until the request settles", async
}
})
test("loads bounded message pages", async () => {
const requests: URL[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
requests.push(url)
return Response.json({ data: [], cursor: requests.length === 1 ? { next: "next" } : {} })
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: { on: () => () => {}, listen: () => () => {} },
}),
dispose,
}))
try {
await setup.data.session.message.sync("ses_refresh")
await setup.data.session.message.loadMore("ses_refresh")
expect(requests).toHaveLength(2)
expect(Object.fromEntries(requests[0].searchParams)).toEqual({ limit: "20", order: "desc" })
expect(Object.fromEntries(requests[1].searchParams)).toEqual({ cursor: "next", limit: "20" })
} finally {
setup.dispose()
}
})
async function wait(check: () => boolean) {
const started = Date.now()
while (!check()) {

View file

@ -33,63 +33,65 @@ describe("reuseTimelineRows", () => {
name: "reuses an unchanged context group",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:a", ["a", "b"])],
expected: ["assistant-part:user-1:context:a"],
expected: ["assistant-part:context:a"],
reused: [[0, 0]],
},
{
name: "preserves the group key when a member is appended",
previous: [context("context:a", ["a"])],
rows: [context("context:a", ["a", "b"])],
expected: ["assistant-part:user-1:context:a"],
expected: ["assistant-part:context:a"],
reused: [],
},
{
name: "preserves a patch group key when a member is appended",
previous: [patch("patch:a", ["a"])],
rows: [patch("patch:a", ["a", "b"])],
expected: ["assistant-part:user-1:patch:a"],
expected: ["assistant-part:patch:a"],
reused: [],
},
{
name: "preserves the group key when the first member is removed",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:b", ["b"])],
expected: ["assistant-part:user-1:context:a"],
expected: ["assistant-part:context:a"],
reused: [],
},
{
name: "lets only the natural owner retain an old key after a split",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:a", ["a"]), context("context:b", ["b"])],
expected: ["assistant-part:user-1:context:a", "assistant-part:user-1:context:b"],
expected: ["assistant-part:context:a", "assistant-part:context:b"],
reused: [],
},
{
name: "chooses the earliest prior key when groups merge",
previous: [context("context:a", ["a"]), context("context:b", ["b"])],
rows: [context("context:b", ["b", "a"])],
expected: ["assistant-part:user-1:context:a"],
expected: ["assistant-part:context:a"],
reused: [],
},
{
name: "reserves an old key for its natural owner when two new groups compete",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:b", ["b"]), context("context:a", ["a"])],
expected: ["assistant-part:user-1:context:b", "assistant-part:user-1:context:a"],
expected: ["assistant-part:context:b", "assistant-part:context:a"],
reused: [],
},
{
name: "does not reuse context identity across user messages",
// A history prepend can regroup a page-boundary turn under its real user
// message; the same parts must keep their identity across that move.
name: "reuses context identity when the same parts move to another user message",
previous: [context("context:a", ["a", "b"], { userMessageID: "user-1" })],
rows: [context("context:b", ["b"], { userMessageID: "user-2" })],
expected: ["assistant-part:user-2:context:b"],
expected: ["assistant-part:context:a"],
reused: [],
},
{
name: "does not reuse context identity across assistant messages",
previous: [context("context:assistant-1:a", ["a"], { messageID: "assistant-1" })],
rows: [context("context:assistant-2:a", ["a"], { messageID: "assistant-2" })],
expected: ["assistant-part:user-1:context:assistant-2:a"],
expected: ["assistant-part:context:assistant-2:a"],
reused: [],
},
{
@ -103,11 +105,7 @@ describe("reuseTimelineRows", () => {
name: "does not create accidental key collisions",
previous: [context("context:a", ["a", "b", "c"])],
rows: [context("context:b", ["b"]), context("context:a", ["a"]), context("context:c", ["c"])],
expected: [
"assistant-part:user-1:context:b",
"assistant-part:user-1:context:a",
"assistant-part:user-1:context:c",
],
expected: ["assistant-part:context:b", "assistant-part:context:a", "assistant-part:context:c"],
reused: [],
},
])("$name", ({ previous, rows, expected, reused }) => {
@ -197,4 +195,5 @@ describe("createTimelineProjection", () => {
expect(second.rows[0]).toBe(first.rows[0])
expect(second.rows[1]).toBe(first.rows[1])
})
})

View file

@ -318,7 +318,7 @@ export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefine
const groupByPart = new Map<string, PriorGroup>()
previous.forEach((row, index) => {
if (row._tag !== "AssistantPart" || row.group.type === "part") return
row.group.refs.forEach((ref) => groupByPart.set(groupPartKey(row.userMessageID, ref), { index, row }))
row.group.refs.forEach((ref) => groupByPart.set(groupPartKey(ref), { index, row }))
})
const reserved = new Map<string, number>()
rows.forEach((row, index) => {
@ -413,7 +413,7 @@ function stabilizeGroupKey(
) {
if (row._tag !== "AssistantPart" || row.group.type === "part") return row
const existing = row.group.refs.reduce<PriorGroup | undefined>((result, ref) => {
const candidate = groupByPart.get(groupPartKey(row.userMessageID, ref))
const candidate = groupByPart.get(groupPartKey(ref))
if (!candidate) return result
const key = TimelineRow.key(candidate.row)
if (claimed.has(key)) return result
@ -432,8 +432,10 @@ function stabilizeGroupKey(
})
}
function groupPartKey(userMessageID: string, ref: PartRef) {
return `${userMessageID}:${ref.messageID}:${ref.partID}`
// Part refs are globally unique; keying by the turn would break reuse when a
// page-boundary turn regroups under its real user message after a history prepend.
function groupPartKey(ref: PartRef) {
return `${ref.messageID}:${ref.partID}`
}
function renderable(content: Content, showReasoning: boolean) {

View file

@ -29,10 +29,10 @@ describe("current session timeline rows", () => {
expect(result.activeMessageID).toBe("msg_3")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_1",
"assistant-part:msg_1:part:msg_2:msg_2:text:0",
"assistant-part:part:msg_2:msg_2:text:0",
"turn-gap:msg_3",
"user-message:msg_3",
"assistant-part:msg_3:part:msg_4:msg_4:reasoning:0",
"assistant-part:part:msg_4:msg_4:reasoning:0",
])
})
@ -79,7 +79,7 @@ describe("current session timeline rows", () => {
expect(result.activeMessageID).toBe("msg_assistant")
expect(result.rows.map(TimelineRow.key)).toEqual([
"notice:msg_notice",
"assistant-part:msg_assistant:part:msg_assistant:msg_assistant:text:0",
"assistant-part:part:msg_assistant:msg_assistant:text:0",
])
})
@ -140,10 +140,10 @@ describe("current session timeline rows", () => {
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_user",
"notice:msg_agent",
"assistant-part:msg_user:part:msg_assistant_1:msg_assistant_1:text:0",
"assistant-part:part:msg_assistant_1:msg_assistant_1:text:0",
"notice:msg_background",
"notice:msg_model",
"assistant-part:msg_user:part:msg_assistant_2:msg_assistant_2:text:0",
"assistant-part:part:msg_assistant_2:msg_assistant_2:text:0",
"notice:msg_restart",
"notice:msg_skill",
"notice:msg_compaction",
@ -414,9 +414,9 @@ describe("current session timeline rows", () => {
expect(keys).toEqual([
"user-message:msg_user",
"assistant-part:msg_user:context:msg_assistant_1:tool_0",
"assistant-part:msg_user:part:msg_assistant_2:tool_0",
"assistant-part:msg_user:context:msg_assistant_3:tool_0",
"assistant-part:context:msg_assistant_1:tool_0",
"assistant-part:part:msg_assistant_2:tool_0",
"assistant-part:context:msg_assistant_3:tool_0",
])
})

View file

@ -88,8 +88,11 @@ export namespace TimelineRow {
return `notice:${row.messageID}`
case "TurnDivider":
return `turn-divider:${row.userMessageID}`
// Keyed by part identity alone: a page boundary can truncate the leading turn,
// and its rows regroup under the real user message once older history loads.
// The group key already carries the owning message and part IDs.
case "AssistantPart":
return `assistant-part:${row.userMessageID}:${row.group.key}`
return `assistant-part:${row.group.key}`
case "Thinking":
return `thinking:${row.userMessageID}`
case "Error":

View file

@ -1,5 +1,5 @@
diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs
index e470032a9572b3ced764ca02238a8c6be435a9d4..65cd7ba4159d47d4c0cfb1adea69281ee30ba28a 100644
index e470032a9572b3ced764ca02238a8c6be435a9d4..93770cdc02c570ce6aaa2ce256792b940c25e4d3 100644
--- a/dist/cjs/index.cjs
+++ b/dist/cjs/index.cjs
@@ -289,7 +289,7 @@ class Virtualizer {
@ -11,16 +11,78 @@ index e470032a9572b3ced764ca02238a8c6be435a9d4..65cd7ba4159d47d4c0cfb1adea69281e
const prevCount = prevOptions.count;
const nextCount = merged.count;
const measurements = this.getMeasurements();
@@ -303,7 +303,7 @@ class Virtualizer {
@@ -299,11 +299,20 @@ class Virtualizer {
const didEdgeKeysChange = didCountChange || prevCount > 0 && nextCount > 0 && (merged.getItemKey(0) !== prevFirstKey || merged.getItemKey(nextCount - 1) !== prevLastKey);
if (didEdgeKeysChange) {
edgeKeysChanged = true;
+ // A data change can legitimately re-key the rows around the current offset
+ // (e.g. a truncated leading chat turn regrouping once a prepended page loads
+ // its parent). Capture fallback anchors below the primary one so the scroll
+ // position survives even when the nearest keys disappear.
const item = prevCount > 0 ? this.getVirtualItemForOffset(this.getScrollOffset()) ?? measurements[0] : null;
if (item) {
anchor = [item.key, this.getScrollOffset() - item.start];
- anchor = [item.key, this.getScrollOffset() - item.start];
+ anchor = [];
+ for (let i = item.index; i < prevCount && anchor.length < 100; i++) {
+ const candidate = measurements[i];
+ if (!candidate) break;
+ anchor.push([candidate.key, this.getScrollOffset() - candidate.start]);
+ }
}
- const behavior = merged.followOnAppend === true ? "auto" : merged.followOnAppend || null;
+ const behavior = merged.anchorTo === "end" ? merged.followOnAppend === true ? "auto" : merged.followOnAppend || null : null;
if (behavior && nextCount > prevCount && this.isAtEnd(prevOptions.scrollEndThreshold) && (prevCount === 0 || merged.getItemKey(nextCount - 1) !== prevLastKey)) {
followOnAppend = behavior;
}
@@ -725,17 +725,20 @@ class Virtualizer {
@@ -316,30 +325,31 @@ class Virtualizer {
}
let anchorResolved = false;
let anchorDelta = 0;
- if (anchor && this.scrollOffset !== null) {
- const [anchorKey, anchorOffset] = anchor;
+ let resolvedAnchor = null;
+ if (anchor && anchor.length > 0 && this.scrollOffset !== null) {
const newMeasurements = this.getMeasurements();
const { count, getItemKey } = this.options;
- let idx = 0;
- while (idx < count && getItemKey(idx) !== anchorKey) {
- idx++;
- }
- if (idx < count) {
+ const indexByKey = new Map();
+ for (let i = 0; i < count; i++) indexByKey.set(getItemKey(i), i);
+ for (const [anchorKey, anchorOffset] of anchor) {
+ const idx = indexByKey.get(anchorKey);
+ if (idx === void 0) continue;
const anchorItem = newMeasurements[idx];
- if (anchorItem) {
- const newOffset = Math.max(0, anchorItem.start + anchorOffset);
- if (newOffset !== this.scrollOffset) {
- anchorDelta = newOffset - this.scrollOffset;
- this.scrollOffset = newOffset;
- anchorResolved = true;
- }
+ if (!anchorItem) continue;
+ resolvedAnchor = [anchorKey, anchorOffset];
+ const newOffset = Math.max(0, anchorItem.start + anchorOffset);
+ if (newOffset !== this.scrollOffset) {
+ anchorDelta = newOffset - this.scrollOffset;
+ this.scrollOffset = newOffset;
+ anchorResolved = true;
}
+ break;
}
}
if (anchorResolved || followOnAppend) {
this.pendingScrollAnchor = [
- anchorResolved ? anchor[0] : null,
- anchorResolved ? anchor[1] : 0,
+ anchorResolved ? resolvedAnchor[0] : null,
+ anchorResolved ? resolvedAnchor[1] : 0,
followOnAppend,
anchorDelta
];
@@ -725,17 +735,20 @@ class Virtualizer {
this.getMeasurements(),
this.getSize(),
this.getScrollOffset(),
@ -71,7 +133,7 @@ index 6b43c0aea7ed9eeef75cbfb1351fcbd243913bdd..7be2680967934ddfbc4583a210a3d11e
getVirtualIndexes: {
(): number[];
diff --git a/dist/esm/index.js b/dist/esm/index.js
index 2495b26cf2c3589213546b3958eaadf2eb6b751d..01373d68c540e022bd7be59d307a3e39a5b0b899 100644
index 2495b26cf2c3589213546b3958eaadf2eb6b751d..dc062edd6438f315bd8dddcf231986ad72158477 100644
--- a/dist/esm/index.js
+++ b/dist/esm/index.js
@@ -287,7 +287,7 @@ class Virtualizer {
@ -83,16 +145,78 @@ index 2495b26cf2c3589213546b3958eaadf2eb6b751d..01373d68c540e022bd7be59d307a3e39
const prevCount = prevOptions.count;
const nextCount = merged.count;
const measurements = this.getMeasurements();
@@ -301,7 +301,7 @@ class Virtualizer {
@@ -297,11 +297,20 @@ class Virtualizer {
const didEdgeKeysChange = didCountChange || prevCount > 0 && nextCount > 0 && (merged.getItemKey(0) !== prevFirstKey || merged.getItemKey(nextCount - 1) !== prevLastKey);
if (didEdgeKeysChange) {
edgeKeysChanged = true;
+ // A data change can legitimately re-key the rows around the current offset
+ // (e.g. a truncated leading chat turn regrouping once a prepended page loads
+ // its parent). Capture fallback anchors below the primary one so the scroll
+ // position survives even when the nearest keys disappear.
const item = prevCount > 0 ? this.getVirtualItemForOffset(this.getScrollOffset()) ?? measurements[0] : null;
if (item) {
anchor = [item.key, this.getScrollOffset() - item.start];
- anchor = [item.key, this.getScrollOffset() - item.start];
+ anchor = [];
+ for (let i = item.index; i < prevCount && anchor.length < 100; i++) {
+ const candidate = measurements[i];
+ if (!candidate) break;
+ anchor.push([candidate.key, this.getScrollOffset() - candidate.start]);
+ }
}
- const behavior = merged.followOnAppend === true ? "auto" : merged.followOnAppend || null;
+ const behavior = merged.anchorTo === "end" ? merged.followOnAppend === true ? "auto" : merged.followOnAppend || null : null;
if (behavior && nextCount > prevCount && this.isAtEnd(prevOptions.scrollEndThreshold) && (prevCount === 0 || merged.getItemKey(nextCount - 1) !== prevLastKey)) {
followOnAppend = behavior;
}
@@ -723,17 +723,20 @@ class Virtualizer {
@@ -314,30 +323,31 @@ class Virtualizer {
}
let anchorResolved = false;
let anchorDelta = 0;
- if (anchor && this.scrollOffset !== null) {
- const [anchorKey, anchorOffset] = anchor;
+ let resolvedAnchor = null;
+ if (anchor && anchor.length > 0 && this.scrollOffset !== null) {
const newMeasurements = this.getMeasurements();
const { count, getItemKey } = this.options;
- let idx = 0;
- while (idx < count && getItemKey(idx) !== anchorKey) {
- idx++;
- }
- if (idx < count) {
+ const indexByKey = new Map();
+ for (let i = 0; i < count; i++) indexByKey.set(getItemKey(i), i);
+ for (const [anchorKey, anchorOffset] of anchor) {
+ const idx = indexByKey.get(anchorKey);
+ if (idx === void 0) continue;
const anchorItem = newMeasurements[idx];
- if (anchorItem) {
- const newOffset = Math.max(0, anchorItem.start + anchorOffset);
- if (newOffset !== this.scrollOffset) {
- anchorDelta = newOffset - this.scrollOffset;
- this.scrollOffset = newOffset;
- anchorResolved = true;
- }
+ if (!anchorItem) continue;
+ resolvedAnchor = [anchorKey, anchorOffset];
+ const newOffset = Math.max(0, anchorItem.start + anchorOffset);
+ if (newOffset !== this.scrollOffset) {
+ anchorDelta = newOffset - this.scrollOffset;
+ this.scrollOffset = newOffset;
+ anchorResolved = true;
}
+ break;
}
}
if (anchorResolved || followOnAppend) {
this.pendingScrollAnchor = [
- anchorResolved ? anchor[0] : null,
- anchorResolved ? anchor[1] : 0,
+ anchorResolved ? resolvedAnchor[0] : null,
+ anchorResolved ? resolvedAnchor[1] : 0,
followOnAppend,
anchorDelta
];
@@ -723,17 +733,20 @@ class Virtualizer {
this.getMeasurements(),
this.getSize(),
this.getScrollOffset(),
@ -117,9 +241,18 @@ index 2495b26cf2c3589213546b3958eaadf2eb6b751d..01373d68c540e022bd7be59d307a3e39
// Pass the typed array so binary search + forward-walk can read
// start/end directly from Float64Array, skipping the Proxy traps.
diff --git a/src/index.ts b/src/index.ts
index dc6f1010c4d4758de9c46fb8d69209e582e47171..6988f58f7406ee64789ad9ad44f519ed4cf9a00f 100644
index dc6f1010c4d4758de9c46fb8d69209e582e47171..5d8bf755e285e4d0688d5e41ed768c60e4267131 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -567,7 +567,7 @@ export class Virtualizer<
const prevOptions = this.options as
| Required<VirtualizerOptions<TScrollElement, TItemElement>>
| undefined
- let anchor: [Key, number] | null = null
+ let anchor: Array<[Key, number]> | null = null
let followOnAppend: ScrollBehavior | null = null
let edgeKeysChanged = false
@@ -575,7 +575,6 @@ export class Virtualizer<
prevOptions !== undefined &&
prevOptions.enabled &&
@ -128,7 +261,28 @@ index dc6f1010c4d4758de9c46fb8d69209e582e47171..6988f58f7406ee64789ad9ad44f519ed
this.scrollElement !== null
) {
const prevCount = prevOptions.count
@@ -611,9 +610,11 @@ export class Virtualizer<
@@ -600,6 +599,10 @@ export class Virtualizer<
if (didEdgeKeysChange) {
edgeKeysChanged = true
+ // A data change can legitimately re-key the rows around the current offset
+ // (e.g. a truncated leading chat turn regrouping once a prepended page loads
+ // its parent). Capture fallback anchors below the primary one so the scroll
+ // position survives even when the nearest keys disappear.
const item =
prevCount > 0
? (this.getVirtualItemForOffset(this.getScrollOffset()) ??
@@ -607,13 +610,20 @@ export class Virtualizer<
: null
if (item) {
- anchor = [item.key, this.getScrollOffset() - item.start]
+ anchor = []
+ for (let i = item.index; i < prevCount && anchor.length < 100; i++) {
+ const candidate = measurements[i]
+ if (!candidate) break
+ anchor.push([candidate.key, this.getScrollOffset() - candidate.start])
+ }
}
const behavior =
@ -143,7 +297,64 @@ index dc6f1010c4d4758de9c46fb8d69209e582e47171..6988f58f7406ee64789ad9ad44f519ed
if (
behavior &&
@@ -1410,16 +1411,25 @@ export class Virtualizer<
@@ -646,35 +656,36 @@ export class Virtualizer<
// frame, producing a visible "jump" on prepend with dynamic sizes.
let anchorResolved = false
let anchorDelta = 0
- if (anchor && this.scrollOffset !== null) {
- const [anchorKey, anchorOffset] = anchor
+ let resolvedAnchor: [Key, number] | null = null
+ if (anchor && anchor.length > 0 && this.scrollOffset !== null) {
const newMeasurements = this.getMeasurements()
const { count, getItemKey } = this.options
- let idx = 0
- while (idx < count && getItemKey(idx) !== anchorKey) {
- idx++
- }
- if (idx < count) {
+ const indexByKey = new Map<Key, number>()
+ for (let i = 0; i < count; i++) indexByKey.set(getItemKey(i), i)
+ for (const [anchorKey, anchorOffset] of anchor) {
+ const idx = indexByKey.get(anchorKey)
+ if (idx === undefined) continue
const anchorItem = newMeasurements[idx]
- if (anchorItem) {
- // Clamp to the reachable range's lower bound — anchorOffset may
- // have been derived from a transiently negative scrollOffset
- // (rubber-band), and a negative tracked offset never self-heals
- // when the element cannot scroll (#1229).
- const newOffset = Math.max(0, anchorItem.start + anchorOffset)
- if (newOffset !== this.scrollOffset) {
- anchorDelta = newOffset - this.scrollOffset
- this.scrollOffset = newOffset
- anchorResolved = true
- }
+ if (!anchorItem) continue
+ resolvedAnchor = [anchorKey, anchorOffset]
+ // Clamp to the reachable range's lower bound — anchorOffset may
+ // have been derived from a transiently negative scrollOffset
+ // (rubber-band), and a negative tracked offset never self-heals
+ // when the element cannot scroll (#1229).
+ const newOffset = Math.max(0, anchorItem.start + anchorOffset)
+ if (newOffset !== this.scrollOffset) {
+ anchorDelta = newOffset - this.scrollOffset
+ this.scrollOffset = newOffset
+ anchorResolved = true
}
+ break
}
}
if (anchorResolved || followOnAppend) {
this.pendingScrollAnchor = [
- anchorResolved ? anchor![0] : null,
- anchorResolved ? anchor![1] : 0,
+ anchorResolved ? resolvedAnchor![0] : null,
+ anchorResolved ? resolvedAnchor![1] : 0,
followOnAppend,
anchorDelta,
]
@@ -1410,16 +1421,25 @@ export class Virtualizer<
this.getSize(),
this.getScrollOffset(),
this.options.lanes,