Delegate message virtualizer scroll policies to TanStack

This commit is contained in:
murashit 2026-06-22 08:22:07 +09:00
parent e4a5be4828
commit bc4e47aa41
2 changed files with 210 additions and 206 deletions

View file

@ -29,18 +29,21 @@ interface MessageVirtualizerReadingAnchor {
top: number;
}
type MessageVirtualizerScrollLock = { kind: "none" } | { kind: "end" } | { kind: "anchor"; anchor: MessageVirtualizerReadingAnchor | null };
type MessageVirtualizerReadingAnchorState = { kind: "inactive" } | { kind: "active"; anchor: MessageVirtualizerReadingAnchor | null };
type MessageVirtualizerUserScrollState = "idle" | "pending" | "active";
interface MessageVirtualizerLayoutReconcileRequest {
requireSettleFrame: boolean;
scheduleSettle: boolean;
clampAnchorToEnd: boolean;
interface MessageVirtualizerScrollPolicyReconcileRequest {
// Follow-end needs a bounded settle when the browser clamps before DOM height catches up.
requireFollowEndSettleFrame: boolean;
scheduleFollowEndSettle: boolean;
// Reading-anchor restores can overshoot when measured content shrinks near the end.
clampReadingAnchorToEnd: boolean;
}
const MESSAGE_BOTTOM_THRESHOLD = 4;
const MESSAGE_BLOCK_ESTIMATE_SIZE = 96;
const MESSAGE_SCROLL_TO_END_SETTLE_ATTEMPTS = 4;
const MESSAGE_FOLLOW_END_SETTLE_ATTEMPTS = 4;
export interface MessageStreamVirtualizerView {
getTotalSize(): number;
getVirtualItems(): VirtualItem[];
@ -52,18 +55,19 @@ interface MessageVirtualizerRuntime {
virtualizer: Virtualizer<HTMLElement, HTMLElement>;
cleanupVirtualizer: (() => void) | null;
blocks: readonly MessageStreamBlock[];
pendingLayoutReconcile: MessageVirtualizerLayoutReconcileRequest | null;
pendingScrollPolicyReconcile: MessageVirtualizerScrollPolicyReconcileRequest | null;
onVirtualizerChange: (() => void) | null;
scrollLock: MessageVirtualizerScrollLock;
followingEnd: boolean;
readingAnchor: MessageVirtualizerReadingAnchorState;
viewportMeasurementsInvalid: boolean;
viewportRestoreFrame: number | null;
virtualizerChangeFrame: number | null;
settleScrollToEndFrame: number | null;
settleScrollToEndAttemptsRemaining: number;
followEndSettleFrame: number | null;
followEndSettleAttemptsRemaining: number;
userScroll: MessageVirtualizerUserScrollState;
}
type MessageVirtualizerFrameKey = "viewportRestoreFrame" | "virtualizerChangeFrame" | "settleScrollToEndFrame";
type MessageVirtualizerFrameKey = "viewportRestoreFrame" | "virtualizerChangeFrame" | "followEndSettleFrame";
export interface MessageStreamVirtualizerOptions {
blocks: readonly MessageStreamBlock[];
@ -115,7 +119,7 @@ export function useMessageStreamVirtualizer({
useLayoutEffect(() => {
// Run before the render effect below: requests made during one commit reconcile after the DOM for the next commit exists.
reconcileMessageVirtualizerLayoutAfterCommit(runtime);
reconcileMessageVirtualizerScrollPolicyAfterCommit(runtime);
});
useLayoutEffect(() => {
@ -147,14 +151,15 @@ function createMessageVirtualizerRuntime(): MessageVirtualizerRuntime {
blocks: [],
virtualizer: null as never,
cleanupVirtualizer: null,
pendingLayoutReconcile: null,
pendingScrollPolicyReconcile: null,
onVirtualizerChange: null,
scrollLock: { kind: "none" },
followingEnd: false,
readingAnchor: { kind: "inactive" },
viewportMeasurementsInvalid: false,
viewportRestoreFrame: null,
virtualizerChangeFrame: null,
settleScrollToEndFrame: null,
settleScrollToEndAttemptsRemaining: 0,
followEndSettleFrame: null,
followEndSettleAttemptsRemaining: 0,
userScroll: "idle",
};
runtime.virtualizer = new Virtualizer(messageVirtualizerOptions(runtime));
@ -168,16 +173,12 @@ function renderMessageVirtualizer(runtime: MessageVirtualizerRuntime, container:
const appendingBlocks = blocks.length > runtime.blocks.length;
const shouldFollowAppend =
appendingBlocks && (hasMessageVirtualizerEndLock(runtime) || isMessageVirtualizerObservedAtEnd(runtime, container));
if (isMessageVirtualizerInsertingBeforeExistingBlocks(runtime.blocks, blocks)) {
setMessageVirtualizerAnchorLock(runtime, captureMessageVirtualizerReadingAnchorValue(runtime));
cancelPendingMessageVirtualizerReconcile(runtime);
}
appendingBlocks && (isMessageVirtualizerFollowingEnd(runtime) || isMessageVirtualizerObservedAtEnd(runtime, container));
runtime.blocks = blocks;
if (blocks.length > 0 && shouldFollowAppend) setMessageVirtualizerEndLock(runtime);
if (blocks.length > 0 && shouldFollowAppend) setMessageVirtualizerFollowEnd(runtime);
updateMessageVirtualizerOptions(runtime);
updateMessageVirtualizer(runtime.virtualizer);
requestMessageVirtualizerLayoutReconcile(runtime);
requestMessageVirtualizerScrollPolicyReconcile(runtime);
}
function applyMessageVirtualizerScrollCommand(runtime: MessageVirtualizerRuntime, command: MessageStreamScrollCommand): void {
@ -195,33 +196,10 @@ function applyMessageVirtualizerScrollCommand(runtime: MessageVirtualizerRuntime
}
}
function isMessageVirtualizerInsertingBeforeExistingBlocks(
previousBlocks: readonly MessageStreamBlock[],
nextBlocks: readonly MessageStreamBlock[],
): boolean {
if (previousBlocks.length === 0 || nextBlocks.length <= previousBlocks.length) return false;
const matchedIndexes: number[] = [];
let searchStart = 0;
for (const previousBlock of previousBlocks) {
const nextIndex = findMessageVirtualizerBlockIndexByKey(nextBlocks, previousBlock.key, searchStart);
if (nextIndex < 0) return false;
matchedIndexes.push(nextIndex);
searchStart = nextIndex + 1;
}
return matchedIndexes.some((nextIndex, previousIndex) => nextIndex !== previousIndex);
}
function findMessageVirtualizerBlockIndexByKey(blocks: readonly MessageStreamBlock[], key: unknown, startIndex: number): number {
for (let index = startIndex; index < blocks.length; index += 1) {
if (Object.is(blocks[index]?.key, key)) return index;
}
return -1;
}
function getMessageVirtualizerTotalSize(runtime: MessageVirtualizerRuntime): number {
const totalSize = runtime.virtualizer.getTotalSize();
const container = runtime.container;
if (!container || !isMessageVirtualizerAnchorLocked(runtime)) return totalSize;
if (!container || !isMessageVirtualizerReadingAnchorActive(runtime)) return totalSize;
return Math.max(totalSize, container.scrollTop + container.clientHeight + MESSAGE_BOTTOM_THRESHOLD + 1);
}
@ -230,27 +208,27 @@ function getMessageVirtualizerItems(runtime: MessageVirtualizerRuntime): Virtual
}
function measureMessageVirtualizerElement(runtime: MessageVirtualizerRuntime, element: HTMLElement | null): void {
prepareMessageVirtualizerMeasurement(runtime);
prepareMessageVirtualizerScrollPolicyForMeasurement(runtime);
runtime.virtualizer.measureElement(element);
if (element) {
requestMessageVirtualizerLayoutReconcile(runtime, {
requestMessageVirtualizerScrollPolicyReconcile(runtime, {
notify: true,
requireSettleFrame: true,
requireFollowEndSettleFrame: true,
});
}
}
function prepareMessageVirtualizerMeasurement(runtime: MessageVirtualizerRuntime): void {
const wasFollowingEnd = hasMessageVirtualizerEndLock(runtime);
const scrollOffsetMovedAwayFromEnd = syncMessageVirtualizerScrollOffset(runtime);
if (wasFollowingEnd && !scrollOffsetMovedAwayFromEnd) setMessageVirtualizerEndLock(runtime);
function prepareMessageVirtualizerScrollPolicyForMeasurement(runtime: MessageVirtualizerRuntime): void {
const wasFollowingEnd = isMessageVirtualizerFollowingEnd(runtime);
const scrollOffsetMovedAwayFromEnd = syncMessageVirtualizerDomScrollOffset(runtime);
if (wasFollowingEnd && !scrollOffsetMovedAwayFromEnd) setMessageVirtualizerFollowEnd(runtime);
}
function reconcileMessageVirtualizerLayoutAfterCommit(runtime: MessageVirtualizerRuntime): void {
const request = runtime.pendingLayoutReconcile;
function reconcileMessageVirtualizerScrollPolicyAfterCommit(runtime: MessageVirtualizerRuntime): void {
const request = runtime.pendingScrollPolicyReconcile;
if (!request) return;
runtime.pendingLayoutReconcile = null;
reconcileMessageVirtualizerLayout(runtime, request);
runtime.pendingScrollPolicyReconcile = null;
reconcileMessageVirtualizerScrollPolicy(runtime, request);
}
function measureRenderedMessageBlocks(runtime: MessageVirtualizerRuntime): void {
@ -275,10 +253,11 @@ function renderedMessageBlockElements(container: HTMLElement | null): HTMLElemen
);
}
function syncMessageVirtualizerScrollOffset(runtime: MessageVirtualizerRuntime): boolean {
function syncMessageVirtualizerDomScrollOffset(runtime: MessageVirtualizerRuntime): boolean {
const container = runtime.container;
if (!container) return false;
if (hasMessageVirtualizerEndLock(runtime) && hasPendingMessageVirtualizerScrollToEnd(runtime)) return false;
// Ignore stale programmatic scroll events while follow-end settle is still authoritative.
if (isMessageVirtualizerFollowingEnd(runtime) && hasPendingMessageVirtualizerFollowEndSettle(runtime)) return false;
const offset = container.scrollTop;
const previousOffset = runtime.virtualizer.scrollOffset;
const offsetChanged = previousOffset !== null && offset !== previousOffset;
@ -286,9 +265,9 @@ function syncMessageVirtualizerScrollOffset(runtime: MessageVirtualizerRuntime):
runtime.virtualizer.scrollDirection = offset < previousOffset ? "backward" : "forward";
}
runtime.virtualizer.scrollOffset = offset;
const scrollOffsetMovedAwayFromEnd = messageVirtualizerScrollOffsetMovedAwayFromEnd(runtime, container, offsetChanged);
const scrollOffsetMovedAwayFromEnd = didMessageVirtualizerDomOffsetMoveAwayFromEnd(runtime, container, offsetChanged);
if (scrollOffsetMovedAwayFromEnd) {
setMessageVirtualizerAnchorLock(runtime, runtime.scrollLock.kind === "anchor" ? runtime.scrollLock.anchor : null);
setMessageVirtualizerReadingAnchor(runtime, runtime.readingAnchor.kind === "active" ? runtime.readingAnchor.anchor : null);
}
return scrollOffsetMovedAwayFromEnd;
}
@ -298,7 +277,7 @@ function isMessageVirtualizerObservedAtEnd(runtime: MessageVirtualizerRuntime, c
return isElementAtEnd(container, runtime.virtualizer.getTotalSize(), MESSAGE_BOTTOM_THRESHOLD);
}
function messageVirtualizerScrollOffsetMovedAwayFromEnd(
function didMessageVirtualizerDomOffsetMoveAwayFromEnd(
runtime: MessageVirtualizerRuntime,
container: HTMLElement,
offsetChanged: boolean,
@ -306,32 +285,37 @@ function messageVirtualizerScrollOffsetMovedAwayFromEnd(
return offsetChanged && !isMessageVirtualizerObservedAtEnd(runtime, container);
}
function hasPendingMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRuntime): boolean {
return runtime.settleScrollToEndFrame !== null;
function hasPendingMessageVirtualizerFollowEndSettle(runtime: MessageVirtualizerRuntime): boolean {
return runtime.followEndSettleFrame !== null;
}
function setMessageVirtualizerEndLock(runtime: MessageVirtualizerRuntime): void {
runtime.scrollLock = { kind: "end" };
function setMessageVirtualizerFollowEnd(runtime: MessageVirtualizerRuntime): void {
runtime.followingEnd = true;
resetMessageVirtualizerReadingAnchor(runtime);
updateMessageVirtualizerOptions(runtime);
}
function clearMessageVirtualizerEndLock(runtime: MessageVirtualizerRuntime): void {
if (runtime.scrollLock.kind === "end") runtime.scrollLock = { kind: "none" };
function clearMessageVirtualizerFollowEnd(runtime: MessageVirtualizerRuntime): void {
runtime.followingEnd = false;
}
function setMessageVirtualizerAnchorLock(runtime: MessageVirtualizerRuntime, anchor: MessageVirtualizerReadingAnchor | null): void {
runtime.scrollLock = { kind: "anchor", anchor };
function setMessageVirtualizerReadingAnchor(runtime: MessageVirtualizerRuntime, anchor: MessageVirtualizerReadingAnchor | null): void {
runtime.readingAnchor = { kind: "active", anchor };
}
function resetMessageVirtualizerReadingAnchor(runtime: MessageVirtualizerRuntime): void {
runtime.readingAnchor = { kind: "inactive" };
}
function resetMessageVirtualizerScrollPolicy(runtime: MessageVirtualizerRuntime): void {
runtime.followingEnd = false;
resetMessageVirtualizerReadingAnchor(runtime);
updateMessageVirtualizerOptions(runtime);
}
function resetMessageVirtualizerScrollLock(runtime: MessageVirtualizerRuntime): void {
runtime.scrollLock = { kind: "none" };
updateMessageVirtualizerOptions(runtime);
}
function cancelPendingMessageVirtualizerReconcile(runtime: MessageVirtualizerRuntime): void {
cancelSettledMessageVirtualizerScrollToEnd(runtime);
runtime.pendingLayoutReconcile = null;
function cancelPendingMessageVirtualizerScrollPolicyReconcile(runtime: MessageVirtualizerRuntime): void {
cancelMessageVirtualizerFollowEndSettle(runtime);
runtime.pendingScrollPolicyReconcile = null;
}
function setMessageVirtualizerChangeHandler(runtime: MessageVirtualizerRuntime, callback: (() => void) | null): void {
@ -343,7 +327,7 @@ function pinMessageVirtualizerToBottom(runtime: MessageVirtualizerRuntime, conta
attachMessageVirtualizer(runtime, container);
updateMessageVirtualizerOptions(runtime);
updateMessageVirtualizer(runtime.virtualizer);
requestMessageVirtualizerEndLock(runtime, { notify: true });
requestMessageVirtualizerFollowEnd(runtime, { notify: true });
}
function resetMessageVirtualizerMeasurements(runtime: MessageVirtualizerRuntime): void {
@ -366,10 +350,10 @@ function handleMessageVirtualizerViewportRect(
cancelViewportRestoreMessageVirtualizerReset(runtime);
return;
}
if (hasMessageVirtualizerEndLock(runtime)) {
requestMessageVirtualizerEndLock(runtime, {
if (isMessageVirtualizerFollowingEnd(runtime)) {
requestMessageVirtualizerFollowEnd(runtime, {
...(options.notify === undefined ? {} : { notify: options.notify }),
requireSettleFrame: true,
requireFollowEndSettleFrame: true,
});
}
if (!runtime.viewportMeasurementsInvalid) return;
@ -384,7 +368,7 @@ function scheduleViewportRestoreMessageVirtualizerReset(runtime: MessageVirtuali
runtime.viewportMeasurementsInvalid = false;
resetMessageVirtualizerMeasurements(runtime);
// Viewport restore is equivalent to activating/resuming the panel: rebuild stale measurements, then return to the end.
requestMessageVirtualizerEndLock(runtime, { notify: true });
requestMessageVirtualizerFollowEnd(runtime, { notify: true });
});
}
@ -409,14 +393,14 @@ function scrollMessageVirtualizerByPage(runtime: MessageVirtualizerRuntime, dire
function disposeMessageVirtualizer(runtime: MessageVirtualizerRuntime): void {
cancelViewportRestoreMessageVirtualizerReset(runtime);
cancelDeferredMessageVirtualizerChange(runtime);
cancelPendingMessageVirtualizerReconcile(runtime);
cancelPendingMessageVirtualizerScrollPolicyReconcile(runtime);
runtime.cleanupVirtualizer?.();
runtime.cleanupVirtualizer = null;
runtime.container = null;
runtime.blocks = [];
runtime.pendingLayoutReconcile = null;
runtime.pendingScrollPolicyReconcile = null;
runtime.onVirtualizerChange = null;
resetMessageVirtualizerScrollLock(runtime);
resetMessageVirtualizerScrollPolicy(runtime);
runtime.viewportMeasurementsInvalid = false;
runtime.userScroll = "idle";
}
@ -431,25 +415,25 @@ function attachMessageVirtualizer(runtime: MessageVirtualizerRuntime, container:
function detachMessageVirtualizer(runtime: MessageVirtualizerRuntime): void {
cancelViewportRestoreMessageVirtualizerReset(runtime);
cancelDeferredMessageVirtualizerChange(runtime);
cancelPendingMessageVirtualizerReconcile(runtime);
cancelPendingMessageVirtualizerScrollPolicyReconcile(runtime);
runtime.cleanupVirtualizer?.();
runtime.cleanupVirtualizer = null;
runtime.container = null;
runtime.blocks = [];
resetMessageVirtualizerScrollLock(runtime);
resetMessageVirtualizerScrollPolicy(runtime);
runtime.viewportMeasurementsInvalid = false;
runtime.userScroll = "idle";
runtime.pendingLayoutReconcile = null;
runtime.pendingScrollPolicyReconcile = null;
}
function resetMessageVirtualizer(runtime: MessageVirtualizerRuntime, container: HTMLElement): void {
cancelViewportRestoreMessageVirtualizerReset(runtime);
cancelDeferredMessageVirtualizerChange(runtime);
cancelSettledMessageVirtualizerScrollToEnd(runtime);
cancelMessageVirtualizerFollowEndSettle(runtime);
runtime.cleanupVirtualizer?.();
runtime.cleanupVirtualizer = null;
runtime.container = container;
resetMessageVirtualizerScrollLock(runtime);
resetMessageVirtualizerScrollPolicy(runtime);
runtime.virtualizer = new Virtualizer(messageVirtualizerOptions(runtime));
configureMessageVirtualizerSizeAdjustment(runtime);
runtime.cleanupVirtualizer = mountMessageVirtualizer(runtime);
@ -465,8 +449,8 @@ function messageVirtualizerOptions(runtime: MessageVirtualizerRuntime): Virtuali
initialRect: scrollElementRect(runtime.container),
initialOffset: () => runtime.container?.scrollTop ?? 0,
getItemKey: (index: number) => runtime.blocks[index]?.key ?? index,
anchorTo: hasMessageVirtualizerEndLock(runtime) ? ("end" as const) : ("start" as const),
followOnAppend: false,
anchorTo: "end",
followOnAppend: "auto",
scrollEndThreshold: MESSAGE_BOTTOM_THRESHOLD,
paddingStart: paddingBlock,
paddingEnd: paddingBlock,
@ -522,12 +506,13 @@ function handleMessageVirtualizerObservedOffset(
if (!element) return;
const userIntendedScroll = observeMessageVirtualizerUserScroll(runtime, isScrolling);
if (isMessageVirtualizerObservedOffsetAtEnd(runtime, instance, element, offset)) {
setMessageVirtualizerEndLock(runtime);
setMessageVirtualizerFollowEnd(runtime);
return;
}
if (!userIntendedScroll) return;
setMessageVirtualizerAnchorLock(runtime, captureMessageVirtualizerReadingAnchorValue(runtime));
cancelPendingMessageVirtualizerReconcile(runtime);
clearMessageVirtualizerFollowEnd(runtime);
setMessageVirtualizerReadingAnchor(runtime, captureMessageVirtualizerReadingAnchorValue(runtime));
cancelPendingMessageVirtualizerScrollPolicyReconcile(runtime);
}
function updateMessageVirtualizerOptions(runtime: MessageVirtualizerRuntime): void {
@ -559,7 +544,7 @@ function cancelDeferredMessageVirtualizerChange(runtime: MessageVirtualizerRunti
function configureMessageVirtualizerSizeAdjustment(runtime: MessageVirtualizerRuntime): void {
// TanStack Virtual exposes this as an instance field rather than a normal option in v3.17.
runtime.virtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false;
runtime.virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => item.end <= (instance.scrollOffset ?? 0);
}
function scrollMessageVirtualizerElement(
@ -574,137 +559,143 @@ function scrollMessageVirtualizerElement(
function scrollMessageVirtualizerBy(runtime: MessageVirtualizerRuntime, delta: number): void {
const container = runtime.container;
if (!container) return;
syncMessageVirtualizerScrollOffset(runtime);
const currentScrollSize = Math.max(container.scrollHeight, runtime.virtualizer.getTotalSize());
const currentScrollEnd = Math.max(0, currentScrollSize - container.clientHeight);
const targetOffset = Math.max(0, Math.min(container.scrollTop + delta, currentScrollEnd));
const targetAtEnd = delta > 0 && currentScrollEnd - targetOffset <= MESSAGE_BOTTOM_THRESHOLD;
if (Math.abs(container.scrollTop - targetOffset) <= 1) {
if (targetAtEnd) setMessageVirtualizerEndLock(runtime);
syncMessageVirtualizerDomScrollOffset(runtime);
const initialOffset = container.scrollTop;
runtime.virtualizer.scrollBy(delta);
syncMessageVirtualizerDomScrollOffset(runtime);
const reachedEnd = delta > 0 && isMessageVirtualizerObservedAtEnd(runtime, container);
if (Math.abs(container.scrollTop - initialOffset) <= 1) {
if (reachedEnd) setMessageVirtualizerFollowEnd(runtime);
return;
}
clearMessageVirtualizerEndLock(runtime);
cancelPendingMessageVirtualizerReconcile(runtime);
runtime.virtualizer.scrollToOffset(targetOffset);
syncMessageVirtualizerScrollOffset(runtime);
clearMessageVirtualizerFollowEnd(runtime);
cancelPendingMessageVirtualizerScrollPolicyReconcile(runtime);
updateMessageVirtualizer(runtime.virtualizer);
notifyMessageVirtualizerChange(runtime);
if (targetAtEnd) {
setMessageVirtualizerEndLock(runtime);
if (reachedEnd) {
setMessageVirtualizerFollowEnd(runtime);
} else {
setMessageVirtualizerAnchorLock(runtime, captureMessageVirtualizerReadingAnchorValue(runtime));
setMessageVirtualizerReadingAnchor(runtime, captureMessageVirtualizerReadingAnchorValue(runtime));
}
}
function hasMessageVirtualizerEndLock(runtime: MessageVirtualizerRuntime): boolean {
return runtime.scrollLock.kind === "end";
function isMessageVirtualizerFollowingEnd(runtime: MessageVirtualizerRuntime): boolean {
return runtime.followingEnd;
}
function isMessageVirtualizerAnchorLocked(runtime: MessageVirtualizerRuntime): boolean {
return runtime.scrollLock.kind === "anchor";
function isMessageVirtualizerReadingAnchorActive(runtime: MessageVirtualizerRuntime): boolean {
return runtime.readingAnchor.kind === "active";
}
function requestMessageVirtualizerEndLock(
function requestMessageVirtualizerFollowEnd(
runtime: MessageVirtualizerRuntime,
options: { notify?: boolean; requireSettleFrame?: boolean } = {},
options: { notify?: boolean; requireFollowEndSettleFrame?: boolean } = {},
): void {
setMessageVirtualizerEndLock(runtime);
requestMessageVirtualizerLayoutReconcile(runtime, options);
setMessageVirtualizerFollowEnd(runtime);
requestMessageVirtualizerScrollPolicyReconcile(runtime, options);
}
function requestMessageVirtualizerLayoutReconcile(
function requestMessageVirtualizerScrollPolicyReconcile(
runtime: MessageVirtualizerRuntime,
options: { notify?: boolean; requireSettleFrame?: boolean; scheduleSettle?: boolean; clampAnchorToEnd?: boolean } = {},
options: {
notify?: boolean;
requireFollowEndSettleFrame?: boolean;
scheduleFollowEndSettle?: boolean;
clampReadingAnchorToEnd?: boolean;
} = {},
): void {
runtime.pendingLayoutReconcile = mergeMessageVirtualizerLayoutReconcileRequest(runtime.pendingLayoutReconcile, options);
runtime.pendingScrollPolicyReconcile = mergeMessageVirtualizerScrollPolicyReconcileRequest(runtime.pendingScrollPolicyReconcile, options);
if (options.notify) notifyMessageVirtualizerChange(runtime);
}
function mergeMessageVirtualizerLayoutReconcileRequest(
current: MessageVirtualizerLayoutReconcileRequest | null,
options: { requireSettleFrame?: boolean; scheduleSettle?: boolean; clampAnchorToEnd?: boolean },
): MessageVirtualizerLayoutReconcileRequest {
function mergeMessageVirtualizerScrollPolicyReconcileRequest(
current: MessageVirtualizerScrollPolicyReconcileRequest | null,
options: { requireFollowEndSettleFrame?: boolean; scheduleFollowEndSettle?: boolean; clampReadingAnchorToEnd?: boolean },
): MessageVirtualizerScrollPolicyReconcileRequest {
return {
requireSettleFrame: current?.requireSettleFrame === true || options.requireSettleFrame === true,
scheduleSettle: (current?.scheduleSettle ?? true) && options.scheduleSettle !== false,
clampAnchorToEnd: current?.clampAnchorToEnd === true || options.clampAnchorToEnd === true,
requireFollowEndSettleFrame: current?.requireFollowEndSettleFrame === true || options.requireFollowEndSettleFrame === true,
scheduleFollowEndSettle: (current?.scheduleFollowEndSettle ?? true) && options.scheduleFollowEndSettle !== false,
clampReadingAnchorToEnd: current?.clampReadingAnchorToEnd === true || options.clampReadingAnchorToEnd === true,
};
}
function reconcileMessageVirtualizerLayout(runtime: MessageVirtualizerRuntime, request: MessageVirtualizerLayoutReconcileRequest): void {
function reconcileMessageVirtualizerScrollPolicy(
runtime: MessageVirtualizerRuntime,
request: MessageVirtualizerScrollPolicyReconcileRequest,
): void {
if (!runtime.container) return;
if (runtime.scrollLock.kind === "end") {
reconcileMessageVirtualizerEndLock(runtime, request);
if (runtime.followingEnd) {
reconcileMessageVirtualizerFollowEnd(runtime, request);
return;
}
if (runtime.scrollLock.kind === "anchor") {
reconcileMessageVirtualizerAnchorLock(runtime, { clampToEnd: request.clampAnchorToEnd });
if (isMessageVirtualizerReadingAnchorActive(runtime)) {
reconcileMessageVirtualizerReadingAnchor(runtime, { clampToEnd: request.clampReadingAnchorToEnd });
}
}
function reconcileMessageVirtualizerEndLock(
function reconcileMessageVirtualizerFollowEnd(
runtime: MessageVirtualizerRuntime,
options: { requireSettleFrame?: boolean; scheduleSettle?: boolean } = {},
options: { requireFollowEndSettleFrame?: boolean; scheduleFollowEndSettle?: boolean } = {},
): void {
runtime.virtualizer.getTotalSize();
runtime.virtualizer.scrollToEnd();
reconcileMessageVirtualizerDomEnd(runtime);
if (options.scheduleSettle !== false) {
scheduleSettledMessageVirtualizerScrollToEnd(
if (options.scheduleFollowEndSettle !== false) {
scheduleMessageVirtualizerFollowEndSettle(
runtime,
options.requireSettleFrame === undefined ? {} : { requireSettleFrame: options.requireSettleFrame },
options.requireFollowEndSettleFrame === undefined ? {} : { requireFollowEndSettleFrame: options.requireFollowEndSettleFrame },
);
}
}
function scheduleSettledMessageVirtualizerScrollToEnd(
function scheduleMessageVirtualizerFollowEndSettle(
runtime: MessageVirtualizerRuntime,
options: { requireSettleFrame?: boolean } = {},
options: { requireFollowEndSettleFrame?: boolean } = {},
): void {
const container = runtime.container;
if (!container) return;
if (options.requireSettleFrame !== true && !shouldSettleMessageVirtualizerScrollToEnd(runtime, container)) {
cancelSettledMessageVirtualizerScrollToEnd(runtime);
if (options.requireFollowEndSettleFrame !== true && !shouldMessageVirtualizerSettleFollowEnd(runtime, container)) {
cancelMessageVirtualizerFollowEndSettle(runtime);
return;
}
// scrollToEnd can be clamped before the virtualizer height reaches the DOM; keep a bounded post-render settle.
runtime.settleScrollToEndAttemptsRemaining = MESSAGE_SCROLL_TO_END_SETTLE_ATTEMPTS;
if (runtime.settleScrollToEndFrame !== null) return;
scheduleSettledMessageVirtualizerScrollToEndFrame(runtime, container, 2);
runtime.followEndSettleAttemptsRemaining = MESSAGE_FOLLOW_END_SETTLE_ATTEMPTS;
if (runtime.followEndSettleFrame !== null) return;
scheduleMessageVirtualizerFollowEndSettleFrame(runtime, container, 2);
}
function shouldSettleMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRuntime, container: HTMLElement): boolean {
function shouldMessageVirtualizerSettleFollowEnd(runtime: MessageVirtualizerRuntime, container: HTMLElement): boolean {
if (!isElementAtEnd(container, runtime.virtualizer.getTotalSize(), MESSAGE_BOTTOM_THRESHOLD)) return true;
// Hidden/resumed panes can start with rendered blocks but no scroll range yet; give the DOM a few frames to expose it.
return runtime.blocks.length > 0 && container.scrollHeight <= container.clientHeight;
}
function scheduleSettledMessageVirtualizerScrollToEndFrame(
function scheduleMessageVirtualizerFollowEndSettleFrame(
runtime: MessageVirtualizerRuntime,
container: HTMLElement,
delayFrames: number,
): void {
scheduleMessageVirtualizerFrame(runtime, "settleScrollToEndFrame", container, () => {
scheduleMessageVirtualizerFrame(runtime, "followEndSettleFrame", container, () => {
if (runtime.container !== container) {
runtime.settleScrollToEndAttemptsRemaining = 0;
runtime.followEndSettleAttemptsRemaining = 0;
return;
}
if (delayFrames > 1) {
scheduleSettledMessageVirtualizerScrollToEndFrame(runtime, container, delayFrames - 1);
scheduleMessageVirtualizerFollowEndSettleFrame(runtime, container, delayFrames - 1);
return;
}
if (!hasMessageVirtualizerEndLock(runtime)) {
runtime.settleScrollToEndAttemptsRemaining = 0;
if (!isMessageVirtualizerFollowingEnd(runtime)) {
runtime.followEndSettleAttemptsRemaining = 0;
return;
}
runtime.settleScrollToEndAttemptsRemaining = Math.max(0, runtime.settleScrollToEndAttemptsRemaining - 1);
requestMessageVirtualizerLayoutReconcile(runtime, { notify: true, scheduleSettle: false });
runtime.followEndSettleAttemptsRemaining = Math.max(0, runtime.followEndSettleAttemptsRemaining - 1);
requestMessageVirtualizerScrollPolicyReconcile(runtime, { notify: true, scheduleFollowEndSettle: false });
if (
runtime.settleScrollToEndAttemptsRemaining > 0 &&
runtime.followEndSettleAttemptsRemaining > 0 &&
!isElementAtEnd(container, runtime.virtualizer.getTotalSize(), MESSAGE_BOTTOM_THRESHOLD)
) {
scheduleSettledMessageVirtualizerScrollToEndFrame(runtime, container, 1);
scheduleMessageVirtualizerFollowEndSettleFrame(runtime, container, 1);
}
});
}
@ -716,9 +707,9 @@ function reconcileMessageVirtualizerDomEnd(runtime: MessageVirtualizerRuntime):
if (domEnd > container.scrollTop) container.scrollTop = domEnd;
}
function cancelSettledMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRuntime): void {
cancelMessageVirtualizerFrame(runtime, "settleScrollToEndFrame");
runtime.settleScrollToEndAttemptsRemaining = 0;
function cancelMessageVirtualizerFollowEndSettle(runtime: MessageVirtualizerRuntime): void {
cancelMessageVirtualizerFrame(runtime, "followEndSettleFrame");
runtime.followEndSettleAttemptsRemaining = 0;
}
function scheduleMessageVirtualizerFrame(
@ -811,10 +802,10 @@ function measureMessageElement(
const box = entry?.borderBoxSize[0];
const size = box ? Math.round(box.blockSize) : element.offsetHeight || instance.options.estimateSize(instance.indexFromElement(element));
if (entry) {
requestMessageVirtualizerLayoutReconcile(runtime, {
clampAnchorToEnd: isMessageVirtualizerAnchorLocked(runtime),
requestMessageVirtualizerScrollPolicyReconcile(runtime, {
clampReadingAnchorToEnd: isMessageVirtualizerReadingAnchorActive(runtime),
notify: true,
requireSettleFrame: hasMessageVirtualizerEndLock(runtime),
requireFollowEndSettleFrame: isMessageVirtualizerFollowingEnd(runtime),
});
}
return size;
@ -824,42 +815,40 @@ function captureMessageVirtualizerReadingAnchorValue(runtime: MessageVirtualizer
const container = runtime.container;
if (!container) return null;
const scrollOffset = container.scrollTop;
const anchor = runtime.virtualizer.getVirtualItems().find((item) => item.end > scrollOffset);
const anchor = runtime.virtualizer.getVirtualItemForOffset(scrollOffset);
return anchor ? { key: anchor.key, top: anchor.start - scrollOffset } : null;
}
function reconcileMessageVirtualizerAnchorLock(runtime: MessageVirtualizerRuntime, options: { clampToEnd?: boolean } = {}): void {
function reconcileMessageVirtualizerReadingAnchor(runtime: MessageVirtualizerRuntime, options: { clampToEnd?: boolean } = {}): void {
const container = runtime.container;
const lock = runtime.scrollLock;
if (!container || lock.kind !== "anchor" || !lock.anchor) return;
const anchor = lock.anchor;
const state = runtime.readingAnchor;
const anchor = state.kind === "active" ? state.anchor : null;
if (!container || !anchor) return;
const item = runtime.virtualizer.getVirtualItems().find((candidate) => Object.is(candidate.key, anchor.key));
if (!item) return;
const scrollSize = Math.max(container.scrollHeight, runtime.virtualizer.getTotalSize());
const scrollEnd = Math.max(0, scrollSize - container.clientHeight);
const targetOffset = Math.max(0, Math.min(item.start - anchor.top, scrollEnd));
const targetOffset = item.start - anchor.top;
if (Math.abs(container.scrollTop - targetOffset) > 1) {
runtime.virtualizer.scrollToOffset(targetOffset);
syncMessageVirtualizerScrollOffset(runtime);
syncMessageVirtualizerDomScrollOffset(runtime);
updateMessageVirtualizer(runtime.virtualizer);
notifyMessageVirtualizerChange(runtime);
}
if (options.clampToEnd) clampMessageVirtualizerAnchorOffsetToEnd(runtime);
if (options.clampToEnd) clampMessageVirtualizerReadingAnchorOffsetToEnd(runtime);
}
function clampMessageVirtualizerAnchorOffsetToEnd(runtime: MessageVirtualizerRuntime): void {
function clampMessageVirtualizerReadingAnchorOffsetToEnd(runtime: MessageVirtualizerRuntime): void {
const container = runtime.container;
if (!container || !isMessageVirtualizerAnchorLocked(runtime)) return;
if (!container || !isMessageVirtualizerReadingAnchorActive(runtime)) return;
const rawTotalSize = runtime.virtualizer.getTotalSize();
if (container.scrollHeight <= getMessageVirtualizerTotalSize(runtime) + MESSAGE_BOTTOM_THRESHOLD) return;
const rawScrollEnd = Math.max(0, rawTotalSize - container.clientHeight);
if (container.scrollTop <= rawScrollEnd) return;
runtime.virtualizer.scrollToOffset(rawScrollEnd);
syncMessageVirtualizerScrollOffset(runtime);
syncMessageVirtualizerDomScrollOffset(runtime);
updateMessageVirtualizer(runtime.virtualizer);
setMessageVirtualizerEndLock(runtime);
setMessageVirtualizerFollowEnd(runtime);
notifyMessageVirtualizerChange(runtime);
}

View file

@ -54,7 +54,7 @@ describe("TestMessageStreamVirtualizer", () => {
controller.dispose();
});
it("settles at the end after the rendered virtualizer height reaches the DOM", () => {
it("keeps the latest message visible until the rendered virtualizer height reaches the DOM", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
@ -67,7 +67,6 @@ describe("TestMessageStreamVirtualizer", () => {
expect(container.scrollTop).toBe(0);
flushFrames();
expect(container.scrollTop).toBe(0);
delete container.dataset["testScrollHeight"];
flushFrames();
@ -77,7 +76,7 @@ describe("TestMessageStreamVirtualizer", () => {
});
});
it("keeps settling for a few frames when the rendered virtualizer height is delayed", () => {
it("reaches the latest message after delayed rendered virtualizer height catches up", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
@ -90,10 +89,8 @@ describe("TestMessageStreamVirtualizer", () => {
expect(container.scrollTop).toBe(0);
flushFrames();
expect(container.scrollTop).toBe(0);
flushFrames();
expect(container.scrollTop).toBe(0);
delete container.dataset["testScrollHeight"];
flushFrames();
@ -194,7 +191,7 @@ describe("TestMessageStreamVirtualizer", () => {
controller.dispose();
});
it("settles at the end when a pinned item grows before the DOM scroll height catches up", () => {
it("keeps the latest message visible when pinned content grows before DOM scroll height catches up", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
@ -212,7 +209,6 @@ describe("TestMessageStreamVirtualizer", () => {
expect(container.scrollTop).toBe(200);
flushFrames();
expect(container.scrollTop).toBe(200);
delete container.dataset["testScrollHeight"];
flushFrames();
@ -267,7 +263,7 @@ describe("TestMessageStreamVirtualizer", () => {
});
});
it("keeps the pending end settle when a stale programmatic scroll event fires during pinned item growth", () => {
it("does not jump away from the latest message when a stale programmatic scroll event fires during pinned growth", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
@ -292,7 +288,7 @@ describe("TestMessageStreamVirtualizer", () => {
});
});
it("does not postpone the pending end settle while small pinned growth measurements keep arriving", () => {
it("still reaches the latest message when small pinned growth measurements keep arriving", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
@ -319,7 +315,7 @@ describe("TestMessageStreamVirtualizer", () => {
});
});
it("settles at the end when ResizeObserver reports pinned item growth before DOM scroll height catches up", () => {
it("keeps the latest message visible when ResizeObserver reports pinned growth before DOM scroll height catches up", () => {
withResizeObserverEntries((resizeElement, flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
@ -359,7 +355,7 @@ describe("TestMessageStreamVirtualizer", () => {
});
});
it("does not postpone the pending end settle while ResizeObserver keeps reporting small pinned growth", () => {
it("still reaches the latest message while ResizeObserver keeps reporting small pinned growth", () => {
withResizeObserverEntries((resizeElement, flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
@ -514,7 +510,7 @@ describe("TestMessageStreamVirtualizer", () => {
controller.dispose();
});
it("clamps stale unpinned offsets when a visible item shrinks near the end", () => {
it("does not leave blank space after visible content shrinks near the end", () => {
withResizeObserverEntries((resizeElement, flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
@ -552,7 +548,7 @@ describe("TestMessageStreamVirtualizer", () => {
});
});
it("resets stale measurements when the message viewport is restored from zero size", () => {
it("returns to the latest message when an active message viewport is restored from zero size", () => {
withResizeObserver((triggerResize, flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
@ -581,7 +577,7 @@ describe("TestMessageStreamVirtualizer", () => {
});
});
it("resets stale measurements when a hidden-at-start message viewport first renders at a usable size", () => {
it("shows the latest message when a hidden-at-start message viewport first renders at a usable size", () => {
withResizeObserver((_triggerResize, flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 0 });
const controller = createMessageStreamVirtualizerDriver(container);
@ -606,7 +602,7 @@ describe("TestMessageStreamVirtualizer", () => {
});
});
it("scrolls by two text lines for composer edge shortcuts", () => {
it("scrolls in both directions for composer text-line edge shortcuts", () => {
const container = messageContainer({ scrollTop: 120, clientHeight: 100 });
container.style.lineHeight = "18px";
const controller = createMessageStreamVirtualizerDriver(container);
@ -615,13 +611,19 @@ describe("TestMessageStreamVirtualizer", () => {
container.scrollTop = 120;
container.dispatchEvent(new Event("scroll"));
const initialScrollTop = container.scrollTop;
controller.scrollByTextLines(-1);
container.dispatchEvent(new Event("scroll"));
expect(container.scrollTop).toBe(84);
const scrolledUpTop = container.scrollTop;
expect(scrolledUpTop).toBeLessThan(initialScrollTop);
expect(scrolledUpTop).toBeGreaterThan(0);
expect(initialScrollTop - scrolledUpTop).toBeLessThanOrEqual(container.clientHeight / 2);
controller.scrollByTextLines(1);
container.dispatchEvent(new Event("scroll"));
expect(container.scrollTop).toBe(120);
expect(container.scrollTop).toBeGreaterThan(scrolledUpTop);
expect(container.scrollTop).toBeLessThanOrEqual(initialScrollTop);
expect(container.scrollTop - scrolledUpTop).toBeLessThanOrEqual(container.clientHeight / 2);
controller.dispose();
});
@ -659,7 +661,7 @@ describe("TestMessageStreamVirtualizer", () => {
controller.dispose();
});
it("scrolls by text lines from the rendered bottom before a scroll event settles", () => {
it("scrolls composer text-line shortcuts from the currently rendered bottom", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
container.style.lineHeight = "18px";
const controller = createMessageStreamVirtualizerDriver(container);
@ -675,16 +677,18 @@ describe("TestMessageStreamVirtualizer", () => {
measureVirtualItem(controller, key, index, 240);
});
controller.getTotalSize();
expect(container.scrollTop).toBe(380);
const initialScrollTop = container.scrollTop;
expectScrollAtEnd(container);
controller.scrollByTextLines(-1);
container.dispatchEvent(new Event("scroll"));
expect(container.scrollTop).toBe(344);
expect(container.scrollTop).toBeLessThan(initialScrollTop);
expect(initialScrollTop - container.scrollTop).toBeLessThanOrEqual(container.clientHeight / 2);
controller.dispose();
});
it("preserves the visible anchor when composer edge scrolling measures earlier items", () => {
it("keeps the same message visible when composer edge scrolling measures earlier items", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
container.style.lineHeight = "18px";
const controller = createMessageStreamVirtualizerDriver(container);
@ -733,21 +737,26 @@ describe("TestMessageStreamVirtualizer", () => {
controller.scrollByPage(-1);
container.dispatchEvent(new Event("scroll"));
expect(container.scrollTop).toBe(60);
const pageUpTop = container.scrollTop;
expect(pageUpTop).toBeLessThan(220);
expect(pageUpTop).toBeGreaterThanOrEqual(0);
expect(220 - pageUpTop).toBeLessThanOrEqual(container.clientHeight);
controller.scrollByPage(1);
container.dispatchEvent(new Event("scroll"));
expect(container.scrollTop).toBe(220);
expect(container.scrollTop).toBeGreaterThan(pageUpTop);
expect(container.scrollTop).toBeLessThanOrEqual(220);
expect(container.scrollTop - pageUpTop).toBeLessThanOrEqual(container.clientHeight);
container.scrollTop = 510;
container.dispatchEvent(new Event("scroll"));
controller.scrollByPage(1);
container.dispatchEvent(new Event("scroll"));
expect(container.scrollTop).toBe(520);
expectScrollAtEnd(container);
controller.dispose();
});
it("preserves the visible anchor when composer PageUp measurements shrink earlier items", () => {
it("keeps the same message visible when composer PageUp measurements shrink earlier items", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
const keys = numberedKeys("item", 50);
@ -775,7 +784,7 @@ describe("TestMessageStreamVirtualizer", () => {
controller.dispose();
});
it("keeps repeated composer line scrolling anchored when measurements shrink", () => {
it("keeps repeated composer line scrolling relative to the same message when measurements shrink", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
container.style.lineHeight = "18px";
const controller = createMessageStreamVirtualizerDriver(container);
@ -789,7 +798,8 @@ describe("TestMessageStreamVirtualizer", () => {
userScrollTo(container, 4660);
controller.scrollByTextLines(-1);
expect(container.scrollTop).toBe(4624);
expect(container.scrollTop).toBeLessThan(4660);
expect(4660 - container.scrollTop).toBeLessThanOrEqual(container.clientHeight / 2);
const anchorKey = firstVisibleVirtualItemKey(controller, container, keys);
const anchorBefore = virtualItemTop(controller, container, anchorKey);
@ -802,7 +812,8 @@ describe("TestMessageStreamVirtualizer", () => {
expect(virtualItemTop(controller, container, anchorKey)).toBe(anchorBefore);
controller.scrollByTextLines(-1);
expect(virtualItemTop(controller, container, anchorKey)).toBe(anchorBefore + 36);
expect(virtualItemTop(controller, container, anchorKey)).toBeGreaterThan(anchorBefore);
expect(virtualItemTop(controller, container, anchorKey) - anchorBefore).toBeLessThanOrEqual(container.clientHeight / 2);
controller.dispose();
});
@ -912,6 +923,10 @@ function clampScrollTop(container: HTMLElement): void {
container.scrollTop = scrollTop;
}
function expectScrollAtEnd(container: HTMLElement): void {
expect(container.scrollTop).toBe(Math.max(0, container.scrollHeight - container.clientHeight));
}
function measureVirtualItem(controller: MessageStreamVirtualizerDriver, key: string, index: number, height: number): void {
controller.measureElement(measuredElement(key, index, height));
}