diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index ad47380c2ce..8f467a0f09d 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -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", diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts index 86a5175eb8c..6bd37df115b 100644 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -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` diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx index 02a3eaa0761..afbd01f776a 100644 --- a/packages/app/src/session/timeline/virtualizer.tsx +++ b/packages/app/src/session/timeline/virtualizer.tsx @@ -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 } diff --git a/packages/app/test-browser/solid-virtual.test.ts b/packages/app/test-browser/solid-virtual.test.ts index 6796d9c2d1c..b61b2db5822 100644 --- a/packages/app/test-browser/solid-virtual.test.ts +++ b/packages/app/test-browser/solid-virtual.test.ts @@ -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(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) diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 3ec0427071f..1fab136c843 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -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] ?? [] diff --git a/packages/client/test/solid-data.test.ts b/packages/client/test/solid-data.test.ts index c15d31578bf..5f41d92c071 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -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()) { diff --git a/packages/session-ui/src/timeline/projection.test.ts b/packages/session-ui/src/timeline/projection.test.ts index 0594e76a548..550d1984546 100644 --- a/packages/session-ui/src/timeline/projection.test.ts +++ b/packages/session-ui/src/timeline/projection.test.ts @@ -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]) }) + }) diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index 2cd259cd388..3412de5bbed 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -318,7 +318,7 @@ export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefine const groupByPart = new Map() 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() 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((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) { diff --git a/packages/session-ui/src/timeline/rows-current.test.ts b/packages/session-ui/src/timeline/rows-current.test.ts index 5afe0b8a712..d93b66c3cee 100644 --- a/packages/session-ui/src/timeline/rows-current.test.ts +++ b/packages/session-ui/src/timeline/rows-current.test.ts @@ -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", ]) }) diff --git a/packages/session-ui/src/timeline/timeline-row.ts b/packages/session-ui/src/timeline/timeline-row.ts index 192d2b7ddd5..2fd5cea6236 100644 --- a/packages/session-ui/src/timeline/timeline-row.ts +++ b/packages/session-ui/src/timeline/timeline-row.ts @@ -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": diff --git a/patches/@tanstack%2Fvirtual-core@3.17.8.patch b/patches/@tanstack%2Fvirtual-core@3.17.8.patch index 82cf1a97285..0470a6a9260 100644 --- a/patches/@tanstack%2Fvirtual-core@3.17.8.patch +++ b/patches/@tanstack%2Fvirtual-core@3.17.8.patch @@ -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> + | 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() ++ 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,