From 82ff1916b764d04d4b0bacaa4d978a8c3c1afb22 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Tue, 9 Dec 2025 16:18:10 +0000 Subject: [PATCH] Prevent cached session remeasurements and remove logs --- .../ui/src/components/message-block-list.tsx | 2 + .../ui/src/components/message-section.tsx | 75 ++++++++++++------- .../src/components/session/session-view.tsx | 41 +++++----- packages/ui/src/components/virtual-item.tsx | 24 ++++-- 4 files changed, 89 insertions(+), 53 deletions(-) diff --git a/packages/ui/src/components/message-block-list.tsx b/packages/ui/src/components/message-block-list.tsx index 4ea4d828..193c97cc 100644 --- a/packages/ui/src/components/message-block-list.tsx +++ b/packages/ui/src/components/message-block-list.tsx @@ -26,6 +26,7 @@ interface MessageBlockListProps { onFork?: (messageId?: string) => void onContentRendered?: () => void setBottomSentinel: (element: HTMLDivElement | null) => void + suspendMeasurements?: () => boolean } export default function MessageBlockList(props: MessageBlockListProps) { @@ -41,6 +42,7 @@ export default function MessageBlockList(props: MessageBlockListProps) { threshold={VIRTUAL_ITEM_MARGIN_PX} placeholderClass="message-stream-placeholder" virtualizationEnabled={() => !props.loading} + suspendMeasurements={props.suspendMeasurements} > void onFork?: (messageId?: string) => void registerScrollToBottom?: (fn: () => void) => void - requestScrollToBottom?: () => void - isActive: boolean showSidebarToggle?: boolean onSidebarToggle?: () => void forceCompactStatusLayout?: boolean onQuoteSelection?: (text: string, mode: "quote" | "code") => void + isActive?: boolean } export default function MessageSection(props: MessageSectionProps) { @@ -140,8 +139,8 @@ export default function MessageSection(props: MessageSectionProps) { const bottomSentinel = () => bottomSentinelSignal() const setBottomSentinel = (element: HTMLDivElement | null) => { setBottomSentinelSignal(element) + resolvePendingActiveScroll() } - const [scrollToBottomRequest, setScrollToBottomRequest] = createSignal(false) const [autoScroll, setAutoScroll] = createSignal(true) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) @@ -160,6 +159,9 @@ export default function MessageSection(props: MessageSectionProps) { let detachScrollIntentListeners: (() => void) | undefined let hasRestoredScroll = false let suppressAutoScrollOnce = false + let pendingActiveScroll = false + let scrollToBottomFrame: number | null = null + let scrollToBottomDelayedFrame: number | null = null function markUserScrollIntent() { const now = typeof performance !== "undefined" ? performance.now() : Date.now() @@ -197,14 +199,13 @@ export default function MessageSection(props: MessageSectionProps) { function setContainerRef(element: HTMLDivElement | null) { containerRef = element || undefined - if (import.meta.env?.DEV) { - console.debug("[MessageSection] setContainerRef", props.sessionId, Boolean(containerRef)) - } setScrollElement(containerRef) attachScrollIntentListeners(containerRef) if (!containerRef) { clearQuoteSelection() + return } + resolvePendingActiveScroll() } function setShellElement(element: HTMLDivElement | null) { @@ -219,9 +220,6 @@ export default function MessageSection(props: MessageSectionProps) { const hasItems = messageIds().length > 0 const bottomVisible = bottomSentinelVisible() const topVisible = topSentinelVisible() - if (import.meta.env?.DEV) { - console.debug("[MessageSection] sentinel visibility", props.sessionId, { bottomVisible, topVisible }) - } setShowScrollBottomButton(hasItems && !bottomVisible) setShowScrollTopButton(hasItems && !topVisible) } @@ -238,13 +236,6 @@ export default function MessageSection(props: MessageSectionProps) { function scrollToBottom(immediate = false) { if (!containerRef) return const sentinel = bottomSentinel() - if (import.meta.env?.DEV) { - console.debug("[MessageSection] scrollToBottom", props.sessionId, { - immediate, - hasSentinel: Boolean(sentinel), - bottomVisible: bottomSentinelVisible(), - }) - } const behavior = immediate ? "auto" : "smooth" if (!immediate) { suppressAutoScrollOnce = true @@ -253,13 +244,43 @@ export default function MessageSection(props: MessageSectionProps) { setAutoScroll(true) scheduleScrollPersist() } + + function clearScrollToBottomFrames() { + if (scrollToBottomFrame !== null) { + cancelAnimationFrame(scrollToBottomFrame) + scrollToBottomFrame = null + } + if (scrollToBottomDelayedFrame !== null) { + cancelAnimationFrame(scrollToBottomDelayedFrame) + scrollToBottomDelayedFrame = null + } + } + + function requestScrollToBottom(immediate = true) { + if (!containerRef || !bottomSentinel()) { + pendingActiveScroll = true + return + } + pendingActiveScroll = false + clearScrollToBottomFrames() + scrollToBottomFrame = requestAnimationFrame(() => { + scrollToBottomFrame = null + scrollToBottomDelayedFrame = requestAnimationFrame(() => { + scrollToBottomDelayedFrame = null + scrollToBottom(immediate) + }) + }) + } + + function resolvePendingActiveScroll() { + if (!pendingActiveScroll) return + if (!props.isActive) return + requestScrollToBottom(true) + } function scrollToTop(immediate = false) { if (!containerRef) return const behavior = immediate ? "auto" : "smooth" - if (import.meta.env?.DEV) { - console.debug("[MessageSection] scrollToTop", props.sessionId, { immediate }) - } setAutoScroll(false) topSentinel()?.scrollIntoView({ block: "start", inline: "nearest", behavior }) scheduleScrollPersist() @@ -378,19 +399,17 @@ export default function MessageSection(props: MessageSectionProps) { createEffect(() => { if (props.registerScrollToBottom) { - props.registerScrollToBottom(() => scrollToBottom(true)) + props.registerScrollToBottom(() => requestScrollToBottom(true)) } }) + let lastActiveState = false createEffect(() => { - const active = props.isActive - const container = containerRef - if (!container) return - if (active) { - requestAnimationFrame(() => requestAnimationFrame(() => scrollToBottom(true))) - } else { - requestAnimationFrame(() => container.scrollTo({ top: 0, behavior: "auto" })) + const active = Boolean(props.isActive) + if (active && !lastActiveState) { + requestScrollToBottom(true) } + lastActiveState = active }) createEffect(() => { @@ -554,6 +573,7 @@ export default function MessageSection(props: MessageSectionProps) { if (pendingAnchorScroll !== null) { cancelAnimationFrame(pendingAnchorScroll) } + clearScrollToBottomFrames() if (detachScrollIntentListeners) { detachScrollIntentListeners() } @@ -626,6 +646,7 @@ export default function MessageSection(props: MessageSectionProps) { onFork={props.onFork} onContentRendered={handleContentRendered} setBottomSentinel={setBottomSentinel} + suspendMeasurements={() => props.isActive === false} /> diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index d1398a99..eca9c70a 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -1,4 +1,4 @@ -import { Show, createMemo, createEffect, createSignal, type Component } from "solid-js" +import { Show, createMemo, createEffect, type Component } from "solid-js" import type { Session } from "../../types/session" import type { Attachment } from "../../types/attachment" import type { ClientPart } from "../../types/message" @@ -26,25 +26,29 @@ interface SessionViewProps { showSidebarToggle?: boolean onSidebarToggle?: () => void forceCompactStatusLayout?: boolean - isActive: boolean + isActive?: boolean } export const SessionView: Component = (props) => { const session = () => props.activeSessions.get(props.sessionId) const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId)) const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId)) - const [scrollToBottomHandle, setScrollToBottomHandle] = createSignal<(() => void) | null>(null) - createEffect(() => { - if (!props.isActive) return - const handler = scrollToBottomHandle() - if (!handler) return - requestAnimationFrame(() => requestAnimationFrame(() => handler())) - }) const sessionBusy = createMemo(() => { const currentSession = session() if (!currentSession) return false return getSessionBusyStatus(props.instanceId, currentSession.id) }) + let scrollToBottomHandle: (() => void) | undefined + function scheduleScrollToBottom() { + if (!scrollToBottomHandle) return + requestAnimationFrame(() => { + requestAnimationFrame(() => scrollToBottomHandle?.()) + }) + } + createEffect(() => { + if (!props.isActive) return + scheduleScrollToBottom() + }) let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null createEffect(() => { @@ -70,10 +74,10 @@ export const SessionView: Component = (props) => { } async function handleSendMessage(prompt: string, attachments: Attachment[]) { - const handler = scrollToBottomHandle() - if (handler) { - handler() + if (scrollToBottomHandle && import.meta.env?.DEV) { + console.debug("[SessionView] handleSendMessage scroll", props.sessionId) } + scheduleScrollToBottom() await sendMessage(props.instanceId, props.sessionId, prompt, attachments) } @@ -201,12 +205,13 @@ export const SessionView: Component = (props) => { onRevert={handleRevert} onFork={handleFork} isActive={props.isActive} - registerScrollToBottom={(fn) => { - setScrollToBottomHandle(() => fn) - if (props.isActive) { - requestAnimationFrame(() => requestAnimationFrame(() => fn())) - } - }} + registerScrollToBottom={(fn) => { + scrollToBottomHandle = fn + if (props.isActive) { + scheduleScrollToBottom() + } + }} + diff --git a/packages/ui/src/components/virtual-item.tsx b/packages/ui/src/components/virtual-item.tsx index 58e187c7..0fabe326 100644 --- a/packages/ui/src/components/virtual-item.tsx +++ b/packages/ui/src/components/virtual-item.tsx @@ -98,6 +98,7 @@ interface VirtualItemProps { placeholderClass?: string virtualizationEnabled?: Accessor forceVisible?: Accessor + suspendMeasurements?: Accessor onMeasured?: () => void id?: string } @@ -138,10 +139,12 @@ export default function VirtualItem(props: VirtualItemProps) { if (!virtualizationEnabled()) return false return !isIntersecting() }) + const measurementsSuspended = () => Boolean(props.suspendMeasurements?.()) let wrapperRef: HTMLDivElement | undefined - + let contentRef: HTMLDivElement | undefined + let resizeObserver: ResizeObserver | undefined let intersectionCleanup: (() => void) | undefined @@ -176,23 +179,27 @@ export default function VirtualItem(props: VirtualItemProps) { } function updateMeasuredHeight() { - if (!contentRef) return + if (!contentRef || measurementsSuspended()) return const next = contentRef.offsetHeight if (next === measuredHeight()) return persistMeasurement(next) } - + function setupResizeObserver() { - if (!contentRef) return + if (!contentRef || measurementsSuspended()) return cleanupResizeObserver() if (typeof ResizeObserver === "undefined") { updateMeasuredHeight() return } - resizeObserver = new ResizeObserver(() => updateMeasuredHeight()) + resizeObserver = new ResizeObserver(() => { + if (measurementsSuspended()) return + updateMeasuredHeight() + }) resizeObserver.observe(contentRef) } + function refreshIntersectionObserver(targetRoot: Element | Document | null) { cleanupIntersectionObserver() if (!wrapperRef) { @@ -219,7 +226,7 @@ export default function VirtualItem(props: VirtualItemProps) { contentRef = element ?? undefined if (contentRef) { queueMicrotask(() => { - if (shouldHideContent()) return + if (shouldHideContent() || measurementsSuspended()) return updateMeasuredHeight() setupResizeObserver() }) @@ -227,10 +234,10 @@ export default function VirtualItem(props: VirtualItemProps) { cleanupResizeObserver() } } - + createEffect(() => { - if (shouldHideContent()) { + if (shouldHideContent() || measurementsSuspended()) { cleanupResizeObserver() } else if (contentRef) { queueMicrotask(() => { @@ -239,6 +246,7 @@ export default function VirtualItem(props: VirtualItemProps) { }) } }) + createEffect(() => { const key = props.cacheKey