Stabilize message virtualizer scrolling

This commit is contained in:
murashit 2026-06-11 17:00:14 +09:00
parent b931b5b0f2
commit 8987b7e33c
2 changed files with 178 additions and 29 deletions

View file

@ -11,6 +11,10 @@ interface MessageVirtualizerRenderPlan {
shouldScrollToBottom: boolean;
}
interface MessageVirtualizerMeasurePlan {
shouldSettleAtEnd: boolean;
}
const MESSAGE_BOTTOM_THRESHOLD = 4;
const MESSAGE_BLOCK_ESTIMATE_SIZE = 96;
const MESSAGE_SCROLL_TO_END_SETTLE_ATTEMPTS = 4;
@ -37,7 +41,7 @@ interface MessageVirtualizerRuntime {
bottomReconcileCommitGeneration: number | null;
renderGeneration: number;
onVirtualizerChange: (() => void) | null;
pinnedToEnd: boolean;
followEndIntent: boolean;
viewportMeasurementsInvalid: boolean;
viewportRestoreFrame: number | null;
settleScrollToEndFrame: number | null;
@ -144,7 +148,7 @@ function createMessageVirtualizerRuntime(): MessageVirtualizerRuntime {
bottomReconcileCommitGeneration: null,
renderGeneration: 0,
onVirtualizerChange: null,
pinnedToEnd: false,
followEndIntent: false,
viewportMeasurementsInvalid: false,
viewportRestoreFrame: null,
settleScrollToEndFrame: null,
@ -152,6 +156,7 @@ function createMessageVirtualizerRuntime(): MessageVirtualizerRuntime {
userScrollIntentUntil: 0,
};
runtime.virtualizer = new Virtualizer(messageVirtualizerOptions(runtime));
configureMessageVirtualizerSizeAdjustment(runtime);
return runtime;
}
@ -166,17 +171,15 @@ function prepareMessageVirtualizerRender(
const appendingBlocks = blocks.length > runtime.blocks.length;
const shouldFollowAppend =
intent === "auto" &&
appendingBlocks &&
(runtime.pinnedToEnd || isElementAtEnd(container, runtime.virtualizer.getTotalSize(), MESSAGE_BOTTOM_THRESHOLD));
intent === "auto" && appendingBlocks && (runtime.followEndIntent || isMessageVirtualizerObservedAtEnd(runtime, container));
runtime.blocks = blocks;
const shouldScrollToBottom = blocks.length > 0 && (intent === "force-bottom" || intent === "follow-bottom" || shouldFollowAppend);
if (intent === "preserve") {
runtime.pinnedToEnd = false;
runtime.followEndIntent = false;
cancelSettledMessageVirtualizerScrollToEnd(runtime);
runtime.bottomReconcileCommitGeneration = null;
}
runtime.virtualizer.setOptions(messageVirtualizerOptions(runtime));
updateMessageVirtualizerOptions(runtime);
updateMessageVirtualizer(runtime.virtualizer);
return {
@ -213,9 +216,17 @@ function getMessageVirtualizerItems(runtime: MessageVirtualizerRuntime): Virtual
}
function measureMessageVirtualizerElement(runtime: MessageVirtualizerRuntime, element: HTMLElement | null): void {
const shouldSettleAtEnd = shouldMessageVirtualizerFollowEnd(runtime);
const plan = prepareMessageVirtualizerMeasurement(runtime);
runtime.virtualizer.measureElement(element);
if (element && shouldSettleAtEnd) requestMessageVirtualizerScrollToEnd(runtime);
if (element && plan.shouldSettleAtEnd) requestMessageVirtualizerScrollToEnd(runtime);
}
function prepareMessageVirtualizerMeasurement(runtime: MessageVirtualizerRuntime): MessageVirtualizerMeasurePlan {
const wasFollowingEnd = hasMessageVirtualizerFollowEndIntent(runtime);
const scrollOffsetMovedAwayFromEnd = syncMessageVirtualizerScrollOffset(runtime);
return {
shouldSettleAtEnd: hasMessageVirtualizerFollowEndIntent(runtime) || (wasFollowingEnd && !scrollOffsetMovedAwayFromEnd),
};
}
function reconcileMessageVirtualizerBottomAfterCommit(runtime: MessageVirtualizerRuntime): void {
@ -223,9 +234,10 @@ function reconcileMessageVirtualizerBottomAfterCommit(runtime: MessageVirtualize
const reconcileGeneration = runtime.bottomReconcileCommitGeneration;
if (reconcileGeneration === null || reconcileGeneration > runtime.commitGeneration) return;
runtime.bottomReconcileCommitGeneration = null;
if (!runtime.container || !shouldMessageVirtualizerFollowEnd(runtime)) return;
if (!runtime.container || !hasMessageVirtualizerFollowEndIntent(runtime)) return;
runtime.virtualizer.getTotalSize();
runtime.virtualizer.scrollToEnd();
reconcileMessageVirtualizerDomEnd(runtime);
scheduleSettledMessageVirtualizerScrollToEnd(runtime);
}
@ -235,6 +247,41 @@ function measureRenderedMessageBlocks(runtime: MessageVirtualizerRuntime): void
}
}
function syncMessageVirtualizerScrollOffset(runtime: MessageVirtualizerRuntime): boolean {
const container = runtime.container;
if (!container) return false;
if (hasMessageVirtualizerFollowEndIntent(runtime) && hasPendingMessageVirtualizerScrollToEnd(runtime)) return false;
const offset = container.scrollTop;
const previousOffset = runtime.virtualizer.scrollOffset;
const offsetChanged = previousOffset !== null && offset !== previousOffset;
if (previousOffset !== null && offset !== previousOffset) {
runtime.virtualizer.scrollDirection = offset < previousOffset ? "backward" : "forward";
}
runtime.virtualizer.scrollOffset = offset;
const scrollOffsetMovedAwayFromEnd = messageVirtualizerScrollOffsetMovedAwayFromEnd(runtime, container, offsetChanged);
if (scrollOffsetMovedAwayFromEnd) {
runtime.followEndIntent = false;
}
return scrollOffsetMovedAwayFromEnd;
}
function isMessageVirtualizerObservedAtEnd(runtime: MessageVirtualizerRuntime, container = runtime.container): boolean {
if (!container) return false;
return isElementAtEnd(container, runtime.virtualizer.getTotalSize(), MESSAGE_BOTTOM_THRESHOLD);
}
function messageVirtualizerScrollOffsetMovedAwayFromEnd(
runtime: MessageVirtualizerRuntime,
container: HTMLElement,
offsetChanged: boolean,
): boolean {
return offsetChanged && !isMessageVirtualizerObservedAtEnd(runtime, container);
}
function hasPendingMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRuntime): boolean {
return runtime.settleScrollToEndFrame !== null;
}
function setMessageVirtualizerChangeHandler(runtime: MessageVirtualizerRuntime, callback: (() => void) | null): void {
runtime.onVirtualizerChange = callback;
}
@ -242,13 +289,13 @@ function setMessageVirtualizerChangeHandler(runtime: MessageVirtualizerRuntime,
function pinMessageVirtualizerToBottom(runtime: MessageVirtualizerRuntime, container = runtime.container): void {
if (!container) return;
attachMessageVirtualizer(runtime, container);
runtime.virtualizer.setOptions(messageVirtualizerOptions(runtime));
updateMessageVirtualizerOptions(runtime);
updateMessageVirtualizer(runtime.virtualizer);
requestMessageVirtualizerScrollToEnd(runtime);
}
function repinMessageVirtualizerToBottomIfPinned(runtime: MessageVirtualizerRuntime): void {
if (!runtime.pinnedToEnd) return;
if (!runtime.followEndIntent) return;
requestMessageVirtualizerScrollToEnd(runtime);
}
@ -318,7 +365,7 @@ function disposeMessageVirtualizer(runtime: MessageVirtualizerRuntime): void {
runtime.bottomReconcileCommitGeneration = null;
runtime.renderGeneration = 0;
runtime.onVirtualizerChange = null;
runtime.pinnedToEnd = false;
runtime.followEndIntent = false;
runtime.viewportMeasurementsInvalid = false;
runtime.userScrollIntentUntil = 0;
}
@ -339,7 +386,7 @@ function detachMessageVirtualizer(runtime: MessageVirtualizerRuntime): void {
runtime.blocks = [];
runtime.bottomReconcileCommitGeneration = null;
runtime.renderGeneration = 0;
runtime.pinnedToEnd = false;
runtime.followEndIntent = false;
runtime.viewportMeasurementsInvalid = false;
runtime.userScrollIntentUntil = 0;
}
@ -350,8 +397,9 @@ function resetMessageVirtualizer(runtime: MessageVirtualizerRuntime, container:
runtime.cleanupVirtualizer?.();
runtime.cleanupVirtualizer = null;
runtime.container = container;
runtime.pinnedToEnd = false;
runtime.followEndIntent = false;
runtime.virtualizer = new Virtualizer(messageVirtualizerOptions(runtime));
configureMessageVirtualizerSizeAdjustment(runtime);
runtime.cleanupVirtualizer = mountMessageVirtualizer(runtime);
updateMessageVirtualizer(runtime.virtualizer);
}
@ -383,15 +431,12 @@ function messageVirtualizerOptions(runtime: MessageVirtualizerRuntime) {
observeElementOffset(instance, (offset, isScrolling) => {
callback(offset, isScrolling);
const element = instance.scrollElement;
const atEnd = element
? isScrollOffsetAtEnd(offset, element.clientHeight, instance.getTotalSize(), MESSAGE_BOTTOM_THRESHOLD)
: false;
if (atEnd) {
runtime.pinnedToEnd = true;
if (element && isMessageVirtualizerObservedOffsetAtEnd(instance, element, offset)) {
runtime.followEndIntent = true;
return;
}
if (element && hasRecentUserScrollIntent(runtime, element)) {
runtime.pinnedToEnd = false;
if (element && userIntendedMessageVirtualizerScroll(runtime, element)) {
runtime.followEndIntent = false;
cancelSettledMessageVirtualizerScrollToEnd(runtime);
runtime.bottomReconcileCommitGeneration = null;
}
@ -405,21 +450,33 @@ function messageVirtualizerOptions(runtime: MessageVirtualizerRuntime) {
};
}
function updateMessageVirtualizerOptions(runtime: MessageVirtualizerRuntime): void {
runtime.virtualizer.setOptions(messageVirtualizerOptions(runtime));
configureMessageVirtualizerSizeAdjustment(runtime);
}
function configureMessageVirtualizerSizeAdjustment(runtime: MessageVirtualizerRuntime): void {
// TanStack Virtual exposes this as an instance field rather than a normal option in v3.17.
runtime.virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, delta, instance) =>
shouldAdjustMessageScrollPositionOnItemSizeChange(runtime, item, delta, instance);
}
function scrollMessageVirtualizerBy(runtime: MessageVirtualizerRuntime, delta: number): void {
runtime.pinnedToEnd = false;
runtime.followEndIntent = false;
cancelSettledMessageVirtualizerScrollToEnd(runtime);
runtime.bottomReconcileCommitGeneration = null;
runtime.virtualizer.scrollBy(delta);
}
function shouldMessageVirtualizerFollowEnd(runtime: MessageVirtualizerRuntime): boolean {
return runtime.pinnedToEnd;
function hasMessageVirtualizerFollowEndIntent(runtime: MessageVirtualizerRuntime): boolean {
return runtime.followEndIntent;
}
function requestMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRuntime): void {
runtime.pinnedToEnd = true;
runtime.followEndIntent = true;
runtime.virtualizer.getTotalSize();
runtime.virtualizer.scrollToEnd();
reconcileMessageVirtualizerDomEnd(runtime);
scheduleMessageVirtualizerBottomReconcileAfterCommit(runtime);
scheduleSettledMessageVirtualizerScrollToEnd(runtime);
}
@ -452,12 +509,13 @@ function scheduleSettledMessageVirtualizerScrollToEndFrame(
scheduleSettledMessageVirtualizerScrollToEndFrame(runtime, container, delayFrames - 1);
return;
}
if (!shouldMessageVirtualizerFollowEnd(runtime)) {
if (!hasMessageVirtualizerFollowEndIntent(runtime)) {
runtime.settleScrollToEndAttemptsRemaining = 0;
return;
}
const totalSize = runtime.virtualizer.getTotalSize();
runtime.virtualizer.scrollToEnd();
reconcileMessageVirtualizerDomEnd(runtime);
runtime.settleScrollToEndAttemptsRemaining = Math.max(0, runtime.settleScrollToEndAttemptsRemaining - 1);
if (runtime.settleScrollToEndAttemptsRemaining > 0 && !isElementAtEnd(container, totalSize, MESSAGE_BOTTOM_THRESHOLD)) {
scheduleSettledMessageVirtualizerScrollToEndFrame(runtime, container, 1);
@ -465,6 +523,13 @@ function scheduleSettledMessageVirtualizerScrollToEndFrame(
});
}
function reconcileMessageVirtualizerDomEnd(runtime: MessageVirtualizerRuntime): void {
const container = runtime.container;
if (!container) return;
const domEnd = Math.max(0, container.scrollHeight - container.clientHeight);
if (domEnd > container.scrollTop) container.scrollTop = domEnd;
}
function cancelSettledMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRuntime): void {
const container = runtime.container;
if (container && runtime.settleScrollToEndFrame !== null) {
@ -511,6 +576,14 @@ function isElementAtEnd(element: HTMLElement, fallbackScrollSize: number, thresh
return scrollSize - element.clientHeight - element.scrollTop <= threshold;
}
function isMessageVirtualizerObservedOffsetAtEnd(
instance: Virtualizer<HTMLElement, HTMLElement>,
element: HTMLElement,
offset: number,
): boolean {
return isScrollOffsetAtEnd(offset, element.clientHeight, instance.getTotalSize(), MESSAGE_BOTTOM_THRESHOLD);
}
function isScrollOffsetAtEnd(offset: number, viewportSize: number, totalSize: number, threshold: number): boolean {
return totalSize - viewportSize - offset <= threshold;
}
@ -519,7 +592,7 @@ function markMessageVirtualizerUserScrollIntent(runtime: MessageVirtualizerRunti
runtime.userScrollIntentUntil = container.win.performance.now() + MESSAGE_USER_SCROLL_INTENT_WINDOW_MS;
}
function hasRecentUserScrollIntent(runtime: MessageVirtualizerRuntime, container: HTMLElement): boolean {
function userIntendedMessageVirtualizerScroll(runtime: MessageVirtualizerRuntime, container: HTMLElement): boolean {
return container.win.performance.now() <= runtime.userScrollIntentUntil;
}
@ -531,10 +604,21 @@ function measureMessageElement(
): number {
const box = entry?.borderBoxSize[0];
const size = box ? Math.round(box.blockSize) : element.offsetHeight || instance.options.estimateSize(instance.indexFromElement(element));
if (entry && shouldMessageVirtualizerFollowEnd(runtime)) scheduleMessageVirtualizerBottomReconcileAfterCommit(runtime);
if (entry && hasMessageVirtualizerFollowEndIntent(runtime)) scheduleMessageVirtualizerBottomReconcileAfterCommit(runtime);
return size;
}
function shouldAdjustMessageScrollPositionOnItemSizeChange(
runtime: MessageVirtualizerRuntime,
item: VirtualItem,
_delta: number,
instance: Virtualizer<HTMLElement, HTMLElement>,
): boolean {
if (!hasMessageVirtualizerFollowEndIntent(runtime)) return false;
if (runtime.container && !isMessageVirtualizerObservedAtEnd(runtime, runtime.container)) return false;
return item.start < (instance.scrollOffset ?? 0) && instance.scrollDirection !== "backward";
}
function textLineHeight(element: HTMLElement): number {
const style = element.win.getComputedStyle(element);
const lineHeight = Number.parseFloat(style.lineHeight);

View file

@ -218,6 +218,51 @@ describe("TestMessageStreamVirtualizer", () => {
});
});
it("keeps following the end when pinned content grows in the DOM before measurement", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "markdown"], [200, 100], "follow-bottom");
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(200);
container.dataset["testScrollHeight"] = "461";
container.dataset["testClampToScrollHeight"] = "true";
container.dataset["testTotalSize"] = "450";
measureVirtualItem(controller, "markdown", 1, 250);
expect(container.scrollTop).toBe(361);
delete container.dataset["testScrollHeight"];
flushFrames();
expect(container.scrollTop).toBe(350);
controller.dispose();
});
});
it("keeps following the end when a non-last rendered message grows while pinned", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "markdown", "live"], [160, 100, 80], "follow-bottom");
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(240);
container.dataset["testScrollHeight"] = "500";
container.dataset["testClampToScrollHeight"] = "true";
container.dataset["testTotalSize"] = "500";
measureVirtualItem(controller, "markdown", 1, 260);
expect(container.scrollTop).toBe(400);
controller.dispose();
});
});
it("keeps the pending end settle when a stale programmatic scroll event fires during pinned item growth", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
@ -380,6 +425,26 @@ describe("TestMessageStreamVirtualizer", () => {
controller.dispose();
});
it("keeps the current reading offset when an earlier item resizes while unpinned", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "second", "third"], [300, 300, 300], "preserve");
container.scrollTop = 360;
container.dispatchEvent(new Event("scroll"));
container.dataset["testTotalSize"] = "1100";
measureVirtualItem(controller, "first", 0, 500);
expect(container.scrollTop).toBe(360);
container.dataset["testTotalSize"] = "800";
measureVirtualItem(controller, "first", 0, 200);
expect(container.scrollTop).toBe(360);
controller.dispose();
});
it("keeps the reading position when the message viewport shrinks away from the end", () => {
withResizeObserver((triggerResize) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 160 });