diff --git a/bun.lock b/bun.lock index 9c703144d0..2ae1247cae 100644 --- a/bun.lock +++ b/bun.lock @@ -1050,7 +1050,7 @@ ], "patchedDependencies": { "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", - "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", + "@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch", "drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", @@ -1100,7 +1100,7 @@ "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@standard-schema/spec": "1.1.0", "@tailwindcss/vite": "4.1.11", - "@tanstack/solid-virtual": "3.13.32", + "@tanstack/solid-virtual": "3.13.37", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", "@types/bun": "1.3.13", @@ -2938,9 +2938,9 @@ "@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="], - "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.32", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-yhX4A4Kgn+wyTg6Mmu8+zwoMTwjz4K1ucvLfRJ8f0rPGDDAIqSaf0v6oU0yT9+SvrjmUaZQ0VX7g4byexbhNng=="], + "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.37", "", { "dependencies": { "@tanstack/virtual-core": "3.17.8" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-RxJC8n/6nnFHcPvDGMg7ZkqSzCVUco4JNw3ctIChj1HjoKs5I9wt5nsU+DUVtMVEiMfizUxOAx4TPIN8SHjxUQ=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.8", "", {}, "sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], diff --git a/package.json b/package.json index 71983bc3da..261c7ff872 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@opentui/core": "0.5.6", "@opentui/keymap": "0.5.6", "@opentui/solid": "0.5.6", - "@tanstack/solid-virtual": "3.13.32", + "@tanstack/solid-virtual": "3.13.37", "@shikijs/stream": "4.2.0", "@standard-schema/spec": "1.1.0", "ulid": "3.0.1", @@ -174,7 +174,7 @@ "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", - "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", + "@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch", "@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch" } } diff --git a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts index ffe877a82f..fb32e43f9b 100644 --- a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts @@ -1,5 +1,12 @@ import { expect, test } from "@playwright/test" -import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" +import { createTwoFilesPatch } from "diff" +import { + assistantMessage, + setupTimeline, + textPart, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" test("renders completed write content", async ({ page }) => { const id = "prt_file_projection_write" @@ -49,8 +56,101 @@ test("renders a completed single-file patch", async ({ page }) => { const wrapper = page.locator(`[data-timeline-part-id="${id}"]`) const file = wrapper.locator('[data-scope="apply-patch"]') + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) await expect(file.getByRole("button")).toHaveAttribute("aria-expanded", "false") await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0) await file.getByRole("button").click() await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible() + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + + await file.getByRole("button").click() + await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0) + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + + await file.getByRole("button").click() + await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible() + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) +}) + +test("keeps an expanded file diff header at the same viewport position", async ({ page }) => { + const id = "prt_file_projection_anchored_patch" + const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("") + const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n") + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart( + id, + "patch", + "completed", + { patchText: "Update src/anchored.ts" }, + { + metadata: { + files: [ + { + file: "src/anchored.ts", + status: "modified", + patch: createTwoFilesPatch("a/src/anchored.ts", "b/src/anchored.ts", before, after), + additions: 80, + deletions: 80, + }, + ], + }, + }, + ), + textPart("prt_after_anchored_patch", "The diff is ready.\n\n".repeat(4)), + ]), + ], + viewport: { width: 1200, height: 600 }, + }) + + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + const wrapper = page.locator(`[data-timeline-part-id="${id}"]`) + const trigger = wrapper.getByRole("button") + await scroller.evaluate((element) => { + element.scrollTop = element.scrollHeight - element.clientHeight - 0.25 + }) + await expect(trigger).toBeInViewport() + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(0.5) + await trigger.dispatchEvent("wheel", { deltaY: -1, deltaMode: 0 }) + await trigger.dispatchEvent("pointerdown") + const y = await trigger.evaluate((element) => element.getBoundingClientRect().y) + await trigger.dispatchEvent("click") + await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible() + await expect + .poll(() => trigger.evaluate((element, initialY) => Math.abs(element.getBoundingClientRect().y - initialY), y)) + .toBeLessThanOrEqual(5) + + const scrollTop = await scroller.evaluate((element) => element.scrollTop) + await scroller.hover() + await page.mouse.wheel(0, 200) + await timeline.settle(40) + const scrolled = await scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop) + expect(scrolled).toBeGreaterThan(50) + expect(scrolled).toBeLessThan(400) + + const expandedY = await trigger.evaluate((element) => element.getBoundingClientRect().y) + await trigger.click() + await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0) + await expect + .poll(() => + scroller.evaluate((element) => Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop)), + ) + .toBeLessThanOrEqual(1) + await expect.poll(() => trigger.evaluate((element) => element.getBoundingClientRect().y)).toBeGreaterThan(expandedY) + + await trigger.click() + await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible() + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) }) diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index cf6f5a041d..9000f01cdf 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -133,7 +133,7 @@ export function createActiveSessionRegion(input: { const scroller = input.timeline.scroller() if (!scroller || !isScrollKeyTarget(target ?? null, key)) return if (scrollKeyOwner(scroller, target ?? null, key) !== scroller) return - input.timeline.view.markGesture(scroller) + input.timeline.view.markUserScroll(scroller) return } if (event.key.length !== 1 || event.key === "Unidentified" || event.ctrlKey || event.metaKey) return diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 7544d99768..5a3ef35364 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -78,20 +78,18 @@ export function SessionScreen(props: { session: SessionModel }) { onResumeScroll={timeline.actions.resume} setScrollRef={timeline.view.setScrollRef} onScheduleScrollState={timeline.view.scheduleScrollState} - onAutoScrollHandleScroll={timeline.autoScroll.handleScroll} - onMarkScrollGesture={timeline.view.markGesture} - hasScrollGesture={timeline.view.hasGesture()} + onPin={timeline.view.pin} + onUnpin={timeline.view.unpin} onUserScroll={timeline.view.markUserScroll} onHistoryScroll={timeline.view.onHistoryScroll} - onAutoScrollInteraction={timeline.autoScroll.handleInteraction} - shouldAnchorBottom={timeline.view.shouldAnchorBottom()} + onSelectionInteraction={timeline.view.selectionInteraction} + pinned={timeline.view.pinned()} centered={screen.centered()} setContentRef={timeline.view.setContentRef} diffs={review.details.diffs} onReview={review.open} workspaceMoveEligible={composer.workspaceMoveEligible()} onSummaryOpenChange={review.details.setOpen} - setHistoryAnchor={timeline.view.setHistoryAnchor} anchor={timeline.view.anchor} setRevealMessage={timeline.view.setRevealMessage} setScrollToEnd={timeline.view.setScrollToEnd} diff --git a/packages/app/src/session/timeline/interaction.ts b/packages/app/src/session/timeline/interaction.ts index fb02852921..d1ef49b9a2 100644 --- a/packages/app/src/session/timeline/interaction.ts +++ b/packages/app/src/session/timeline/interaction.ts @@ -1,8 +1,7 @@ import type { SessionMessageUser } from "@opencode-ai/client/promise" -import { createAutoScroll } from "@opencode-ai/ui/hooks" import { createResizeObserver } from "@solid-primitives/resize-observer" import { useLocation } from "@solidjs/router" -import { createEffect, on, onCleanup } from "solid-js" +import { createEffect, createSignal, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { useLayout } from "@/shell/state/layout" import type { SessionModel } from "../model" @@ -16,7 +15,6 @@ export function createSessionTimelineInteraction(session: SessionModel) { const [state, setState] = createStore({ messageID: undefined as string | undefined, pendingMessage: undefined as string | undefined, - gestureAt: 0, scroll: { overflow: false, jump: false, @@ -26,13 +24,18 @@ export function createSessionTimelineInteraction(session: SessionModel) { dock: undefined as HTMLDivElement | undefined, }, }) - const autoScroll = createAutoScroll({ working: () => true, overflowAnchor: "none" }) + // The single source of truth for "follow the newest content". The virtualizer pins and unpins + // it from scroll geometry; everything else only expresses explicit intent. + const [pinned, setPinned] = createSignal(true) + const pin = () => setPinned(true) + const unpin = () => { + if (!scroller || scroller.scrollHeight - scroller.clientHeight <= 1) return + setPinned(false) + } let scroller: HTMLDivElement | undefined let dockHeight = 0 let revealMessage = (_id: string) => {} let scrollToEnd = () => {} - let captureHistoryAnchor = () => {} - let restoreHistoryAnchor = (_done: boolean) => {} let scrollMark = 0 let messageMark = 0 let scrollStateFrame: number | undefined @@ -102,10 +105,10 @@ export function createSessionTimelineInteraction(session: SessionModel) { pendingMessage: () => state.pendingMessage, setPendingMessage: (value) => setState("pendingMessage", value), setActiveMessage, - autoScroll: { - pause: autoScroll.pause, - forceScrollToBottom: () => { - autoScroll.resume() + follow: { + unpin, + toBottom: () => { + pin() scrollToEnd() }, }, @@ -117,7 +120,7 @@ export function createSessionTimelineInteraction(session: SessionModel) { }) const resume = () => { setState("messageID", undefined) - autoScroll.resume() + pin() scrollToEnd() clearMessageHash() if (scroller) scheduleScrollState(scroller) @@ -133,21 +136,23 @@ export function createSessionTimelineInteraction(session: SessionModel) { resume() return } - autoScroll.pause() + unpin() scrollToMessage(messages[target], "auto") } - const shouldAnchorBottom = () => - !location.hash && !state.messageID && !state.pendingMessage && !autoScroll.userScrolled() - const markGesture = (target?: EventTarget | null) => { + // A gesture inside a nested scrollable region scrolls that region, not the timeline. + const markUserScroll = (target?: EventTarget | null) => { if (!scroller) return const element = target instanceof Element ? target : undefined const nested = element?.closest("[data-scrollable]") if (nested && nested !== scroller) return - setState("gestureAt", Date.now()) + scrollMark += 1 + } + const selectionInteraction = () => { + const selection = window.getSelection() + if (selection && selection.toString().length > 0) unpin() } const setScrollRef = (element: HTMLDivElement | undefined) => { scroller = element - autoScroll.scrollRef(element) if (!element) return scheduleScrollState(element) fill() @@ -158,15 +163,12 @@ export function createSessionTimelineInteraction(session: SessionModel) { historyRequests.add(owner.key) const before = timeline.messages().length try { - await timeline.history.loadOlder({ - before: () => owner.run(captureHistoryAnchor), - after: (done) => owner.run(() => restoreHistoryAnchor(done)), - }) + await timeline.history.loadOlder() } finally { historyRequests.delete(owner.key) } if (!owner.current() || timeline.messages().length <= before) return - if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !timeline.history.more()) return + if (pinned() || !scroller || scroller.scrollTop >= 200 || !timeline.history.more()) return if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame) historyContinuationFrame = requestAnimationFrame(() => { historyContinuationFrame = undefined @@ -177,7 +179,7 @@ export function createSessionTimelineInteraction(session: SessionModel) { if ( historyRequests.has(session.ownership.key()) || timeline.history.loading() || - !autoScroll.userScrolled() || + pinned() || !scroller || scroller.scrollTop >= 200 ) @@ -189,7 +191,7 @@ export function createSessionTimelineInteraction(session: SessionModel) { fillFrame = requestAnimationFrame(() => { fillFrame = undefined if (!session.identity.params.id || !timeline.ready()) return - if (autoScroll.userScrolled() || timeline.history.loading() || !scroller) return + if (!pinned() || timeline.history.loading() || !scroller) return if (scroller.scrollHeight > scroller.clientHeight + 1 || !timeline.history.more()) return void loadOlder() }) @@ -218,15 +220,16 @@ export function createSessionTimelineInteraction(session: SessionModel) { () => session.identity.params.id, (id, previous) => { if (!id || !previous || id === previous || state.messageID || state.pendingMessage || location.hash) return - autoScroll.resume() + pin() + scrollToEnd() }, ), ) createEffect( on( - autoScroll.userScrolled, - (scrolled) => { - if (scrolled) return + pinned, + (value) => { + if (!value) return setState("messageID", undefined) clearMessageHash() }, @@ -241,11 +244,11 @@ export function createSessionTimelineInteraction(session: SessionModel) { timeline.ready(), timeline.history.more(), timeline.history.loading(), - autoScroll.userScrolled(), + pinned(), visibleUserMessages().length, ] as const, - ([id, ready, more, loading, scrolled]) => { - if (id && ready && more && !loading && !scrolled) fill() + ([id, ready, more, loading, following]) => { + if (id && ready && more && !loading && following) fill() }, { defer: true }, ), @@ -264,8 +267,7 @@ export function createSessionTimelineInteraction(session: SessionModel) { if (next === dockHeight) return const delta = next - dockHeight const stick = scroller - ? !autoScroll.userScrolled() || - scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop < 10 + Math.max(0, delta) + ? pinned() || scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop < 10 + Math.max(0, delta) : false dockHeight = next if (stick) scrollToEnd() @@ -285,7 +287,6 @@ export function createSessionTimelineInteraction(session: SessionModel) { resume, setActiveMessage, }, - autoScroll, lastUserMessage: timeline.lastUserMessage, resource: timeline.resource, ready: timeline.ready, @@ -293,25 +294,19 @@ export function createSessionTimelineInteraction(session: SessionModel) { scroller: () => scroller, view: { anchor, - hasGesture: () => Date.now() - state.gestureAt < 250, - markGesture, - markUserScroll: () => { - scrollMark += 1 - }, + markUserScroll, onHistoryScroll, + pin, + pinned, + selectionInteraction, scheduleScrollState, setContentRef: (element: HTMLDivElement | undefined) => { setState("refs", "content", element) - autoScroll.contentRef(element) if (scroller) scheduleScrollState(scroller) }, setDockRef: (element: HTMLDivElement | undefined) => { setState("refs", "dock", element) }, - setHistoryAnchor: (handlers: { capture: () => void; restore: (done: boolean) => void }) => { - captureHistoryAnchor = handlers.capture - restoreHistoryAnchor = handlers.restore - }, setRevealMessage: (reveal: (id: string) => void) => { revealMessage = reveal }, @@ -319,7 +314,7 @@ export function createSessionTimelineInteraction(session: SessionModel) { setScrollToEnd: (scroll: () => void) => { scrollToEnd = scroll }, - shouldAnchorBottom, + unpin, }, } } diff --git a/packages/app/src/session/timeline/measure.test.ts b/packages/app/src/session/timeline/measure.test.ts deleted file mode 100644 index b66f4e161f..0000000000 --- a/packages/app/src/session/timeline/measure.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { expect, test } from "bun:test" -import { scheduleConnectedMeasure } from "./measure" - -test("does not measure an element detached before the frame", async () => { - const element = document.createElement("div") - document.body.append(element) - let calls = 0 - - scheduleConnectedMeasure(element, () => { - calls += 1 - }) - element.remove() - await new Promise((resolve) => requestAnimationFrame(() => resolve())) - - expect(calls).toBe(0) -}) - -test("measures a connected element on the next frame", async () => { - const element = document.createElement("div") - document.body.append(element) - let calls = 0 - - scheduleConnectedMeasure(element, () => { - calls += 1 - }) - await new Promise((resolve) => requestAnimationFrame(() => resolve())) - - expect(calls).toBe(1) - element.remove() -}) diff --git a/packages/app/src/session/timeline/measure.ts b/packages/app/src/session/timeline/measure.ts deleted file mode 100644 index c53df5f145..0000000000 --- a/packages/app/src/session/timeline/measure.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function scheduleConnectedMeasure(element: T, measure: (element: T) => void) { - return requestAnimationFrame(() => { - if (element.isConnected) measure(element) - }) -} diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 1d8c84106d..85a29e67bc 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -323,13 +323,12 @@ type MessageTimelineProps = { onResumeScroll: () => void setScrollRef: (el: HTMLDivElement | undefined) => void onScheduleScrollState: (el: HTMLDivElement) => void - onAutoScrollHandleScroll: () => void - onMarkScrollGesture: (target?: EventTarget | null) => void - hasScrollGesture: boolean - onUserScroll: () => void + onPin: () => void + onUnpin: () => void + onUserScroll: (target?: EventTarget | null) => void onHistoryScroll: () => void - onAutoScrollInteraction: (event: MouseEvent) => void - shouldAnchorBottom: boolean + onSelectionInteraction: (event: MouseEvent) => void + pinned: boolean centered: boolean setContentRef: (el: HTMLDivElement) => void diffs: Accessor<{ additions: number; deletions: number }[] | undefined> @@ -339,7 +338,6 @@ type MessageTimelineProps = { anchor: (id: string) => string setRevealMessage?: (fn: (id: string) => void) => void setScrollToEnd?: (fn: () => void) => void - setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void } export function MessageTimeline(props: MessageTimelineProps) { @@ -396,28 +394,25 @@ function MessageTimelineView( ) const turnPadding = () => "px-4 md:px-5" const showHeader = createMemo(() => props.data.showHeader() || workspaceSession()) - const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom) - const hasScrollGesture = createMemo(() => props.hasScrollGesture) + const pinned = createMemo(() => props.pinned) const messageByID = projection.messageByID const virtualized = createTimelineVirtualizer({ sessionKey: props.data.sessionKey, projection, showHeader, - shouldAnchorBottom, - hasScrollGesture, + pinned, scroll: () => props.scroll, onResumeScroll: props.onResumeScroll, setScrollRef: props.setScrollRef, setContentRef: props.setContentRef, onScheduleScrollState: props.onScheduleScrollState, - onAutoScrollHandleScroll: props.onAutoScrollHandleScroll, - onAutoScrollInteraction: props.onAutoScrollInteraction, - onMarkScrollGesture: props.onMarkScrollGesture, + onPin: props.onPin, + onUnpin: props.onUnpin, + onSelectionInteraction: props.onSelectionInteraction, onUserScroll: props.onUserScroll, onHistoryScroll: props.onHistoryScroll, setRevealMessage: props.setRevealMessage, setScrollToEnd: props.setScrollToEnd, - setHistoryAnchor: props.setHistoryAnchor, }) const VirtualizedTimeline = virtualized.View const [title, setTitle] = createStore({ diff --git a/packages/app/src/session/timeline/observe-element-offset.ts b/packages/app/src/session/timeline/observe-element-offset.ts index e815423a66..941e190d3a 100644 --- a/packages/app/src/session/timeline/observe-element-offset.ts +++ b/packages/app/src/session/timeline/observe-element-offset.ts @@ -3,6 +3,7 @@ import { observeElementOffset, type Virtualizer } from "@tanstack/solid-virtual" export function observeElementOffsetReconnectAware( instance: Virtualizer, callback: (offset: number, isScrolling: boolean) => void, + onReconnect?: () => void, ) { let active = true const deliver = (offset: number, isScrolling: boolean) => { @@ -54,6 +55,7 @@ export function observeElementOffsetReconnectAware }>() type Projection = Pick< @@ -33,21 +35,20 @@ type Input = { sessionKey: Accessor projection: Projection showHeader: Accessor - shouldAnchorBottom: Accessor - hasScrollGesture: Accessor + /** True while the timeline follows the newest content. Drives every anchoring decision. */ + pinned: Accessor scroll: Accessor<{ overflow: boolean; jump: boolean }> onResumeScroll: () => void setScrollRef: (element: HTMLDivElement | undefined) => void setContentRef: (element: HTMLDivElement) => void onScheduleScrollState: (element: HTMLDivElement) => void - onAutoScrollHandleScroll: () => void - onAutoScrollInteraction: (event: MouseEvent) => void - onMarkScrollGesture: (target?: EventTarget | null) => void - onUserScroll: () => void + onPin: () => void + onUnpin: () => void + onSelectionInteraction: (event: MouseEvent) => void + onUserScroll: (target?: EventTarget | null) => void onHistoryScroll: () => void setRevealMessage?: (fn: (id: string) => void) => void setScrollToEnd?: (fn: () => void) => void - setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void } type ViewProps = { @@ -62,90 +63,31 @@ export function createTimelineVirtualizer(input: Input) { const ownerSessionKey = input.sessionKey() const cached = cache.get(ownerSessionKey) const initialMeasurements = cached?.measurements - const coldBottomMount = !initialMeasurements?.length && input.shouldAnchorBottom() + const coldBottomMount = !initialMeasurements?.length && input.pinned() const [listRoot, setListRoot] = createSignal() const [toolOpen, setToolOpen] = createStore>(cached?.toolOpen ?? {}) const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length || coldBottomMount ? 6 : 20) const rows = input.projection.rows const rowByKey = input.projection.rowByKey - let touchGesture: number | undefined - let prependAnchor: { key: string; offset: number } | undefined - let prependAnchorFrame: number | undefined - let prependLoading = false - let resizePinnedIndexes: number[] = [] - let resizePinFrame: number | undefined - let gestureAnchorFrame: number | undefined + const knownKeys = new Set(rows().map(TimelineRow.key)) + const addedKeys = new Set() + let touchStart: number | undefined + let pointerHeld = false + let maxScroll = 0 let virtualContent: HTMLDivElement | undefined let scrollTop = 0 - const clearPrependAnchor = () => { - prependLoading = false - prependAnchor = undefined - if (prependAnchorFrame === undefined) return - cancelAnimationFrame(prependAnchorFrame) - prependAnchorFrame = undefined - } - - const capturePrependAnchor = () => { - prependLoading = true - updatePrependAnchor() - } - - const updatePrependAnchor = () => { - const root = listRoot() - if (!root) return - const view = root.getBoundingClientRect() - const anchor = [...root.querySelectorAll("[data-timeline-key]")] - .map((element) => ({ element, rect: element.getBoundingClientRect() })) - .filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom) - .sort((a, b) => a.rect.top - b.rect.top)[0] - if (!anchor) return - if (!anchor.element.dataset.timelineKey) return - prependAnchor = { key: anchor.element.dataset.timelineKey, offset: anchor.rect.top - view.top } - } - - const restorePrependAnchor = (done: boolean) => { - if (done) prependLoading = false - applyPrependAnchor() - } - - const applyPrependAnchor = () => { - const root = listRoot() - if (!root || !prependAnchor) return - if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame) - let frames = 0 - let stable = 0 - const apply = () => { - prependAnchorFrame = undefined - const anchor = prependAnchor - if (!anchor) return - const element = root.querySelector(`[data-timeline-key="${CSS.escape(anchor.key)}"]`) - const delta = element - ? element.getBoundingClientRect().top - root.getBoundingClientRect().top - anchor.offset - : undefined - if (delta !== undefined && Math.abs(delta) > 0.5) { - root.scrollTop += delta - stable = 0 - } else { - stable += 1 - } - frames += 1 - if (stable >= 30 || frames >= 180) { - if (!prependLoading) prependAnchor = undefined - return - } - prependAnchorFrame = requestAnimationFrame(apply) - } - prependAnchorFrame = requestAnimationFrame(apply) - } - const virtualizer = createVirtualizer({ get count() { return rows().length }, getScrollElement: () => listRoot() ?? null, - observeElementOffset: observeElementOffsetReconnectAware, - initialOffset: () => (input.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0), + // Route navigation detaches and reattaches the scroll element, which drops its offset. + observeElementOffset: (instance, callback) => + observeElementOffsetReconnectAware(instance, callback, () => { + if (input.pinned()) virtualizer.scrollToEnd() + }), + initialOffset: () => (input.pinned() ? Number.MAX_SAFE_INTEGER : 0), initialMeasurementsCache: initialMeasurements, estimateSize: () => fallbackItemSize, scrollToFn: (offset, options, instance) => { @@ -154,83 +96,62 @@ export function createTimelineVirtualizer(input: Input) { }, get getItemKey() { const items = rows() + items + .map(TimelineRow.key) + .filter((key) => !knownKeys.has(key)) + .forEach((key) => { + knownKeys.add(key) + addedKeys.add(key) + }) return (index: number) => { const row = items[index] if (!row) return `removed:${index}` return TimelineRow.key(row) } }, - anchorTo: "end", - followOnAppend: true, + get anchorTo() { + return input.pinned() ? "end" : "start" + }, + get followOnAppend() { + return input.pinned() + }, scrollEndThreshold: 80, get scrollMargin() { return input.showHeader() ? 64 : 0 }, - overscan: 50, paddingEnd: 64, rangeExtractor: (range) => { const id = input.projection.activeMessageID() const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1 const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() }) return filterVirtualIndexes( - [...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b), + [...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b), range.count, ) }, }) const resizeItem = virtualizer.resizeItem let resizeAnchorScheduled = false - const anchorAfterGesture = () => { - if (gestureAnchorFrame !== undefined) return - const apply = () => { - gestureAnchorFrame = undefined - if (input.hasScrollGesture()) { - gestureAnchorFrame = requestAnimationFrame(apply) - return - } - if (input.shouldAnchorBottom()) virtualizer.scrollToEnd() - } - gestureAnchorFrame = requestAnimationFrame(apply) - } + // Rows measure asynchronously, so the last row can still hold its estimate when TanStack + // reconciles the end. Coalesce one correction per measurement batch, before paint. const anchorResizedBottom = () => { if (resizeAnchorScheduled) return resizeAnchorScheduled = true queueMicrotask(() => { resizeAnchorScheduled = false - if (input.hasScrollGesture()) { - anchorAfterGesture() - return - } - if (!input.shouldAnchorBottom()) return + if (!input.pinned()) return virtualizer.scrollToEnd() }) } virtualizer.resizeItem = (index, size) => { - const item = virtualizer.measurementsCache[index] - const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined - const root = listRoot() - if (root && previous !== undefined && Math.abs(size - previous) > root.clientHeight) { - const view = root.getBoundingClientRect() - resizePinnedIndexes = [...root.querySelectorAll("[data-index]")] - .filter((element) => { - const rect = element.getBoundingClientRect() - return rect.bottom > view.top && rect.top < view.bottom - }) - .map((element) => Number(element.dataset.index)) - if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame) - resizePinFrame = requestAnimationFrame(() => { - resizePinFrame = requestAnimationFrame(() => { - resizePinFrame = undefined - resizePinnedIndexes = [] - }) - }) - } resizeItem(index, size) - if (root && input.shouldAnchorBottom()) anchorResizedBottom() + if (listRoot() && input.pinned()) anchorResizedBottom() } - virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => { - if (input.shouldAnchorBottom()) return false - const first = virtualizer.range?.startIndex + virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { + if (!instance.itemSizeCache.has(item.key) && addedKeys.delete(String(item.key))) { + return item.start < (instance.scrollOffset ?? 0) + instance.scrollAdjustments + } + const first = instance.range?.startIndex return first !== undefined && item.index < first } const virtualItemByKey = createMemo( @@ -244,97 +165,72 @@ export function createTimelineVirtualizer(input: Input) { if (index === undefined) return virtualizer.scrollToIndex(index, { align: "center" }) }) - input.setScrollToEnd?.(() => virtualizer.scrollToEnd()) - input.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor }) + input.setScrollToEnd?.(() => { + input.onPin() + virtualizer.scrollToEnd() + }) }) let overscanFrame: number | undefined onMount(() => { overscanFrame = requestAnimationFrame(() => { - if (input.shouldAnchorBottom()) virtualizer.scrollToEnd() - overscanFrame = requestAnimationFrame(() => { - overscanFrame = undefined - if (renderOverscan() < 20) setRenderOverscan(20) - if (input.shouldAnchorBottom()) virtualizer.scrollToEnd() - }) + overscanFrame = undefined + if (renderOverscan() < 20) setRenderOverscan(20) }) }) - const maybeAnchorBottom = () => { - if (rows().length === 0) return - if (input.hasScrollGesture()) { - anchorAfterGesture() - return - } - if (!input.shouldAnchorBottom()) return - if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame) - clearPrependAnchor() - if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame) - virtualizer.scrollToEnd() - } - let measuredSessionKey = input.sessionKey() createEffect(() => { const key = input.sessionKey() - rows().length if (measuredSessionKey !== key) { measuredSessionKey = key virtualizer.measure() } - maybeAnchorBottom() }) const bindListRoot = (root: HTMLDivElement) => { if (root === listRoot()) return setListRoot(root) + // TanStack owns anchoring; browser scroll anchoring would fight its adjustments. + root.style.overflowAnchor = "none" scrollTop = root.scrollTop + maxScroll = root.scrollHeight - root.clientHeight input.setScrollRef(root) } + // Upward input is the one intent geometry cannot recover: nudging up while still a pixel from + // the end must stop following, even though the resulting position still looks like the end. const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => { - if (!prependLoading) clearPrependAnchor() - const root = event.currentTarget - const delta = normalizeWheelDelta({ - deltaY: event.deltaY, - deltaMode: event.deltaMode, - rootHeight: root.clientHeight, - }) - if (!delta) return - markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: input.onMarkScrollGesture }) + input.onUserScroll(event.target) + if (event.deltaY < 0) input.onUnpin() } const handleListTouchStart = (event: TouchEvent) => { - if (!prependLoading) clearPrependAnchor() - touchGesture = event.touches[0]?.clientY + input.onUserScroll(event.target) + touchStart = event.touches[0]?.clientY } const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => { - const next = event.touches[0]?.clientY - const previous = touchGesture - touchGesture = next - if (next === undefined || previous === undefined) return - const delta = previous - next - if (!delta) return - markBoundaryGesture({ - root: event.currentTarget, - target: event.target, - delta, - onMarkScrollGesture: input.onMarkScrollGesture, - }) - } - - const handleListTouchEnd = () => { - touchGesture = undefined + const current = event.touches[0]?.clientY + if (current === undefined || touchStart === undefined) return + // Dragging the content downward reveals earlier messages. + if (current <= touchStart) return + touchStart = current + input.onUnpin() } + // Drag-selecting past the edge and dragging the scrollbar both scroll without a wheel or key, + // so a held pointer is what separates those from the virtualizer's own measurement adjustments. const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => { - if (!prependLoading) clearPrependAnchor() - input.onMarkScrollGesture(event.target) - } - - const handleListPointerMove = (event: PointerEvent) => { - if (event.buttons !== 1) return - input.onMarkScrollGesture(event.target) + input.onUserScroll(event.target) + pointerHeld = true + const release = () => { + pointerHeld = false + window.removeEventListener("pointerup", release) + window.removeEventListener("pointercancel", release) + } + window.addEventListener("pointerup", release) + window.addEventListener("pointercancel", release) } const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => { @@ -342,22 +238,25 @@ export function createTimelineVirtualizer(input: Input) { if (!key) return if (!isScrollKeyTarget(event.target, key)) return if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return - if (!prependLoading) clearPrependAnchor() - input.onMarkScrollGesture(event.currentTarget) + input.onUserScroll(event.currentTarget) + if (upwardKeys.has(key)) input.onUnpin() } + // Following resumes by arriving at the end, either by scrolling there or by content shrinking + // under a viewport that was already there. Merely resting near the end is not enough, otherwise + // a later scroll would overwrite an upward intent expressed a pixel short of the bottom. const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => { const root = event.currentTarget - const movedUp = root.scrollTop < scrollTop - 0.5 + const previousTop = scrollTop + const previousMaxScroll = maxScroll scrollTop = root.scrollTop - if (prependLoading) updatePrependAnchor() + maxScroll = root.scrollHeight - root.clientHeight + const atEnd = maxScroll - scrollTop <= endEpsilon + const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll + if (maxScroll <= 1 || (atEnd && arrived)) input.onPin() + else if (pointerHeld && scrollTop < previousTop - endEpsilon) input.onUnpin() input.onScheduleScrollState(root) input.onHistoryScroll() - if (!input.hasScrollGesture()) return - if (!movedUp && root.scrollHeight - root.clientHeight - root.scrollTop >= 10) return - input.onUserScroll() - input.onAutoScrollHandleScroll() - input.onMarkScrollGesture(root) } function View(props: ViewProps) { @@ -368,7 +267,6 @@ export function createTimelineVirtualizer(input: Input) { const item = createMemo(() => virtualItemByKey().get(rowProps.rowKey) ?? initialItem) const row = createMemo(() => rowByKey().get(rowProps.rowKey) ?? rows()[item().index] ?? initialRow) const [ready, setReady] = createSignal(initialItem.size <= fallbackItemSize || !props.deferred(initialRow)) - let contentMeasureFrame: number | undefined onMount(() => virtualizer.measureElement(element)) createEffect( @@ -380,10 +278,7 @@ export function createTimelineVirtualizer(input: Input) { { defer: true }, ), ) - onCleanup(() => { - if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame) - queueMicrotask(() => virtualizer.measureElement(null)) - }) + onCleanup(() => virtualizer.measureElement(null)) return (
{props.renderRow(row, () => { setReady(true) - if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame) - contentMeasureFrame = scheduleConnectedMeasure(element, virtualizer.measureElement) })}
@@ -450,13 +343,10 @@ export function createTimelineVirtualizer(input: Input) { onWheel={handleListWheel} onTouchStart={handleListTouchStart} onTouchMove={handleListTouchMove} - onTouchEnd={handleListTouchEnd} - onTouchCancel={handleListTouchEnd} onPointerDown={handleListPointerDown} - onPointerMove={handleListPointerMove} onKeyDown={handleListKeyDown} onScroll={handleListScroll} - onClick={input.onAutoScrollInteraction} + onClick={input.onSelectionInteraction} class="relative min-w-0 w-full h-full" style={{ "--sticky-accordion-top": input.showHeader() ? "48px" : "0px" }} > @@ -485,17 +375,13 @@ export function createTimelineVirtualizer(input: Input) { } onCleanup(() => { - clearPrependAnchor() cache.delete(ownerSessionKey) cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } }) while (cache.size > 16) cache.delete(cache.keys().next().value!) - if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame) - if (gestureAnchorFrame !== undefined) cancelAnimationFrame(gestureAnchorFrame) if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame) input.setScrollRef(undefined) input.setRevealMessage?.(() => {}) input.setScrollToEnd?.(() => {}) - input.setHistoryAnchor?.({ capture: () => {}, restore: () => {} }) }) return { @@ -506,30 +392,3 @@ export function createTimelineVirtualizer(input: Input) { View, } } - -function boundaryTarget(root: HTMLElement, target: EventTarget | null) { - const current = target instanceof Element ? target : undefined - const nested = current?.closest("[data-scrollable]") - if (!(nested instanceof HTMLElement) || nested === root) return undefined - return nested -} - -function markBoundaryGesture(input: { - root: HTMLElement - target: EventTarget | null - delta: number - onMarkScrollGesture: (target?: EventTarget | null) => void -}) { - const target = boundaryTarget(input.root, input.target) - if ( - target && - !shouldMarkBoundaryGesture({ - delta: input.delta, - scrollTop: target.scrollTop, - scrollHeight: target.scrollHeight, - clientHeight: target.clientHeight, - }) - ) - return - input.onMarkScrollGesture(input.root) -} diff --git a/packages/app/src/session/use-session-hash-scroll.ts b/packages/app/src/session/use-session-hash-scroll.ts index 0e3d605a94..67271c3208 100644 --- a/packages/app/src/session/use-session-hash-scroll.ts +++ b/packages/app/src/session/use-session-hash-scroll.ts @@ -15,7 +15,7 @@ export const useSessionHashScroll = (input: { pendingMessage: () => string | undefined setPendingMessage: (value: string | undefined) => void setActiveMessage: (message: SessionMessageUser | undefined) => void - autoScroll: { pause: () => void; forceScrollToBottom: () => void } + follow: { unpin: () => void; toBottom: () => void } scroller: () => HTMLDivElement | undefined anchor: (id: string) => string revealMessage?: (id: string) => void @@ -101,7 +101,7 @@ export const useSessionHashScroll = (input: { const applyHash = (behavior: ScrollBehavior) => { const hash = location.hash.slice(1) if (!hash) { - input.autoScroll.forceScrollToBottom() + input.follow.toBottom() const el = input.scroller() if (el) input.scheduleScrollState(el) return @@ -109,7 +109,7 @@ export const useSessionHashScroll = (input: { const messageId = messageIdFromHash(hash) if (messageId) { - input.autoScroll.pause() + input.follow.unpin() const msg = messageById().get(messageId) if (msg) { scrollToMessage(msg, behavior) @@ -120,12 +120,12 @@ export const useSessionHashScroll = (input: { const target = document.getElementById(hash) if (target) { - input.autoScroll.pause() + input.follow.unpin() scrollToElement(target, behavior) return } - input.autoScroll.forceScrollToBottom() + input.follow.toBottom() const el = input.scroller() if (el) input.scheduleScrollState(el) } @@ -166,7 +166,7 @@ export const useSessionHashScroll = (input: { if (pending) input.setPendingMessage(undefined) if (input.currentMessageId() === targetId && !pending) return - input.autoScroll.pause() + input.follow.unpin() cancel() queue(() => scrollToMessage(msg, "auto")) }) diff --git a/packages/app/test-browser/solid-virtual.test.ts b/packages/app/test-browser/solid-virtual.test.ts index bb8feabe8c..6796d9c2d1 100644 --- a/packages/app/test-browser/solid-virtual.test.ts +++ b/packages/app/test-browser/solid-virtual.test.ts @@ -27,6 +27,34 @@ test("end anchoring survives consecutive resizes when the first scroll write is expect(writes).toEqual([{ offset: 120, adjustments: 80 }]) }) +test("start anchoring preserves a stable visible item across prepends", () => { + 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) + }, + }) + const virtualizer = new Virtualizer(options(["c", "d", "e"])) + virtualizer._willUpdate() + virtualizer.getVirtualItems() + + virtualizer.setOptions(options(["a", "b", "c", "d", "e"])) + virtualizer._willUpdate() + + 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/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index d4a42610d3..91dde93d73 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -1295,7 +1295,7 @@ ToolRegistry.register({ diff --git a/patches/@tanstack%2Fvirtual-core@3.17.3.patch b/patches/@tanstack%2Fvirtual-core@3.17.8.patch similarity index 50% rename from patches/@tanstack%2Fvirtual-core@3.17.3.patch rename to patches/@tanstack%2Fvirtual-core@3.17.8.patch index 2450d4234b..82cf1a9728 100644 --- a/patches/@tanstack%2Fvirtual-core@3.17.3.patch +++ b/patches/@tanstack%2Fvirtual-core@3.17.8.patch @@ -1,8 +1,26 @@ diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs -index 52ae6ca12f8d1c650ee7f1bd55573ee7d4f8b65f..830f646f5bd80f4e00f442291ad6e4c6c508630d 100644 +index e470032a9572b3ced764ca02238a8c6be435a9d4..65cd7ba4159d47d4c0cfb1adea69281ee30ba28a 100644 --- a/dist/cjs/index.cjs +++ b/dist/cjs/index.cjs -@@ -716,17 +716,20 @@ class Virtualizer { +@@ -289,7 +289,7 @@ class Virtualizer { + let anchor = null; + let followOnAppend = null; + let edgeKeysChanged = false; +- if (prevOptions !== void 0 && prevOptions.enabled && merged.enabled && merged.anchorTo === "end" && this.scrollElement !== null) { ++ if (prevOptions !== void 0 && prevOptions.enabled && merged.enabled && this.scrollElement !== null) { + const prevCount = prevOptions.count; + const nextCount = merged.count; + const measurements = this.getMeasurements(); +@@ -303,7 +303,7 @@ class Virtualizer { + if (item) { + anchor = [item.key, this.getScrollOffset() - item.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 { this.getMeasurements(), this.getSize(), this.getScrollOffset(), @@ -27,7 +45,7 @@ index 52ae6ca12f8d1c650ee7f1bd55573ee7d4f8b65f..830f646f5bd80f4e00f442291ad6e4c6 // Pass the typed array so binary search + forward-walk can read // start/end directly from Float64Array, skipping the Proxy traps. diff --git a/dist/cjs/index.d.cts b/dist/cjs/index.d.cts -index c61ee17752565253f795c7fc7d57e86237ecbb52..430c622a18c69407df9f1ca82ee9440e9be287b7 100644 +index cc1001b8a14d06aa73a3a9e8f59870557ee9e7d6..70b202ed436ce1f3283a8465a81faf2e6e2e7983 100644 --- a/dist/cjs/index.d.cts +++ b/dist/cjs/index.d.cts @@ -144,7 +144,7 @@ export declare class Virtualizer prevCount && this.isAtEnd(prevOptions.scrollEndThreshold) && (prevCount === 0 || merged.getItemKey(nextCount - 1) !== prevLastKey)) { + followOnAppend = behavior; + } +@@ -723,17 +723,20 @@ class Virtualizer { this.getMeasurements(), this.getSize(), this.getScrollOffset(), @@ -81,10 +117,33 @@ index 3032c0ca457582be3f47923cba1f7d92c848745c..6e52124048fb3bc7b7ee37e763354b33 // 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 7ad79aacd591c3a4f7855c91b2727a59a9579bca..201406779ee85d2f2e5b01a8ea284aa9a5f694e2 100644 +index dc6f1010c4d4758de9c46fb8d69209e582e47171..6988f58f7406ee64789ad9ad44f519ed4cf9a00f 100644 --- a/src/index.ts +++ b/src/index.ts -@@ -1367,16 +1367,19 @@ export class Virtualizer< +@@ -575,7 +575,6 @@ export class Virtualizer< + prevOptions !== undefined && + prevOptions.enabled && + merged.enabled && +- merged.anchorTo === 'end' && + this.scrollElement !== null + ) { + const prevCount = prevOptions.count +@@ -611,9 +610,11 @@ export class Virtualizer< + } + + const behavior = +- merged.followOnAppend === true +- ? 'auto' +- : merged.followOnAppend || null ++ merged.anchorTo === 'end' ++ ? merged.followOnAppend === true ++ ? 'auto' ++ : merged.followOnAppend || null ++ : null + + if ( + behavior && +@@ -1410,16 +1411,25 @@ export class Virtualizer< this.getSize(), this.getScrollOffset(), this.options.lanes, @@ -96,8 +155,14 @@ index 7ad79aacd591c3a4f7855c91b2727a59a9579bca..201406779ee85d2f2e5b01a8ea284aa9 this.range = null return null } -+ const maxScrollOffset = Math.max(this.options.scrollMargin + this.getTotalSize() - outerSize, 0) -+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset) ++ const maxScrollOffset = Math.max( ++ this.options.scrollMargin + this.getTotalSize() - outerSize, ++ 0, ++ ) ++ const effectiveScrollOffset = Math.min( ++ Math.max(scrollOffset, 0), ++ maxScrollOffset, ++ ) this.range = calculateRangeImpl( measurements, outerSize,