Fix work details stream spacing

This commit is contained in:
murashit 2026-06-21 23:10:31 +09:00
parent 6d00c0665d
commit dc91a461d6
2 changed files with 55 additions and 1 deletions

View file

@ -52,11 +52,12 @@ interface MessageVirtualizerRuntime {
viewportRestoreFrame: number | null;
virtualizerChangeFrame: number | null;
settleScrollToEndFrame: number | null;
anchorClampFrame: number | null;
settleScrollToEndAttemptsRemaining: number;
userScrollIntentUntil: number;
}
type MessageVirtualizerFrameKey = "viewportRestoreFrame" | "virtualizerChangeFrame" | "settleScrollToEndFrame";
type MessageVirtualizerFrameKey = "viewportRestoreFrame" | "virtualizerChangeFrame" | "settleScrollToEndFrame" | "anchorClampFrame";
export interface MessageStreamVirtualizerOptions {
blocks: readonly MessageStreamBlock[];
@ -160,6 +161,7 @@ function createMessageVirtualizerRuntime(): MessageVirtualizerRuntime {
viewportRestoreFrame: null,
virtualizerChangeFrame: null,
settleScrollToEndFrame: null,
anchorClampFrame: null,
settleScrollToEndAttemptsRemaining: 0,
userScrollIntentUntil: 0,
};
@ -384,6 +386,7 @@ function scrollMessageVirtualizerByPage(runtime: MessageVirtualizerRuntime, dire
function disposeMessageVirtualizer(runtime: MessageVirtualizerRuntime): void {
cancelViewportRestoreMessageVirtualizerReset(runtime);
cancelDeferredMessageVirtualizerChange(runtime);
cancelPreservedMessageVirtualizerEndClamp(runtime);
cancelPendingMessageVirtualizerBottomFollow(runtime);
runtime.cleanupVirtualizer?.();
runtime.cleanupVirtualizer = null;
@ -406,6 +409,7 @@ function attachMessageVirtualizer(runtime: MessageVirtualizerRuntime, container:
function detachMessageVirtualizer(runtime: MessageVirtualizerRuntime): void {
cancelViewportRestoreMessageVirtualizerReset(runtime);
cancelDeferredMessageVirtualizerChange(runtime);
cancelPreservedMessageVirtualizerEndClamp(runtime);
cancelPendingMessageVirtualizerBottomFollow(runtime);
runtime.cleanupVirtualizer?.();
runtime.cleanupVirtualizer = null;
@ -419,6 +423,7 @@ function detachMessageVirtualizer(runtime: MessageVirtualizerRuntime): void {
function resetMessageVirtualizer(runtime: MessageVirtualizerRuntime, container: HTMLElement): void {
cancelViewportRestoreMessageVirtualizerReset(runtime);
cancelDeferredMessageVirtualizerChange(runtime);
cancelPreservedMessageVirtualizerEndClamp(runtime);
cancelSettledMessageVirtualizerScrollToEnd(runtime);
runtime.cleanupVirtualizer?.();
runtime.cleanupVirtualizer = null;
@ -656,6 +661,31 @@ function cancelSettledMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerR
runtime.settleScrollToEndAttemptsRemaining = 0;
}
function schedulePreservedMessageVirtualizerEndClamp(runtime: MessageVirtualizerRuntime): void {
const container = runtime.container;
if (!container || runtime.anchorClampFrame !== null) return;
scheduleMessageVirtualizerFrame(runtime, "anchorClampFrame", container, () => {
if (runtime.container !== container || !isMessageVirtualizerPreservingReadingAnchor(runtime)) return;
const scrollEnd = Math.max(0, runtime.virtualizer.getTotalSize() - container.clientHeight);
if (Math.abs(container.scrollTop - scrollEnd) <= MESSAGE_BOTTOM_THRESHOLD) {
updateMessageVirtualizer(runtime.virtualizer);
markMessageVirtualizerFollowEndIntent(runtime);
notifyMessageVirtualizerChange(runtime);
return;
}
if (container.scrollTop < scrollEnd) return;
runtime.virtualizer.scrollToOffset(scrollEnd);
syncMessageVirtualizerScrollOffset(runtime);
updateMessageVirtualizer(runtime.virtualizer);
markMessageVirtualizerFollowEndIntent(runtime);
notifyMessageVirtualizerChange(runtime);
});
}
function cancelPreservedMessageVirtualizerEndClamp(runtime: MessageVirtualizerRuntime): void {
cancelMessageVirtualizerFrame(runtime, "anchorClampFrame");
}
function scheduleMessageVirtualizerFrame(
runtime: MessageVirtualizerRuntime,
key: MessageVirtualizerFrameKey,
@ -743,6 +773,9 @@ 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 && isMessageVirtualizerPreservingReadingAnchor(runtime)) {
schedulePreservedMessageVirtualizerEndClamp(runtime);
}
if (entry && hasMessageVirtualizerFollowEndIntent(runtime)) {
// ResizeObserver measurements can arrive while TanStack and DOM heights are still converging, so keep the bounded settle.
scheduleMessageVirtualizerBottomReconcileAfterCommit(runtime);

View file

@ -463,6 +463,27 @@ describe("TestMessageStreamVirtualizer", () => {
});
});
it("clamps stale unpinned offsets when a visible item shrinks near the end", () => {
withResizeObserverEntries((resizeElement, flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
const details = measuredElement("details", 1, 500);
renderVirtualItems(controller, container, ["first", "details", "last"], [300, 500, 270], "force-bottom");
controller.measureElement(details);
userScrollTo(container, 720);
container.dataset["testTotalSize"] = "590";
container.dataset["testScrollHeight"] = "1070";
resizeElement(details, 20);
flushFrames();
expect(controller.getTotalSize()).toBe(590);
expect(container.scrollTop).toBe(490);
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 });