mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-31 09:45:10 +00:00
fix(app): preserve scroll across history prepends (#44317)
This commit is contained in:
parent
9692bcbc21
commit
6cd3da1d4c
11 changed files with 332 additions and 46 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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] ?? []
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue