diff --git a/src/renderer/src/ConversationSidebar.tsx b/src/renderer/src/ConversationSidebar.tsx index ac04536c..0f3234c3 100644 --- a/src/renderer/src/ConversationSidebar.tsx +++ b/src/renderer/src/ConversationSidebar.tsx @@ -632,9 +632,13 @@ export const ConversationSidebar = memo(function ConversationSidebar({ listScope, Boolean(conversationActionsId), ); + // Same ids keep the same array: conversations change on every streamed + // update, and a new array would rerun the window's layout effect, which + // reads scrollTop and forces a synchronous layout mid-stream. + const filteredIdsKey = filteredConversations.map((conversation) => conversation.id).join("\n"); const filteredIds = useMemo( - () => filteredConversations.map((conversation) => conversation.id), - [filteredConversations], + () => (filteredIdsKey ? filteredIdsKey.split("\n") : []), + [filteredIdsKey], ); const listWindow = useConversationListWindow({ ids: filteredIds, diff --git a/src/renderer/src/OverflowMarquee.test.tsx b/src/renderer/src/OverflowMarquee.test.tsx index cb556106..a2335803 100644 --- a/src/renderer/src/OverflowMarquee.test.tsx +++ b/src/renderer/src/OverflowMarquee.test.tsx @@ -54,6 +54,37 @@ describe('OverflowMarquee', () => { ).toBe('') }) + it('measures from the resize observer instead of forcing layout on mount', () => { + let callback: ResizeObserverCallback | undefined + class FakeResizeObserver { + constructor(next: ResizeObserverCallback) { callback = next } + observe(): void {} + disconnect(): void {} + } + const original = globalThis.ResizeObserver + globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver + try { + const text = '这是一个明显超过会话列表宽度的完整会话名称' + let reads = 0 + const scrollWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollWidth') + Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { configurable: true, get: () => { reads += 1; return 280 } }) + try { + render() + const container = screen.getByTitle(text) + setMeasuredWidth(container, 'clientWidth', 120) + expect(reads).toBe(0) + callback?.([], {} as ResizeObserver) + expect(reads).toBe(1) + expect(container).toHaveAttribute('data-overflowing', 'true') + } finally { + if (scrollWidth) Object.defineProperty(HTMLElement.prototype, 'scrollWidth', scrollWidth) + else Reflect.deleteProperty(HTMLElement.prototype, 'scrollWidth') + } + } finally { + globalThis.ResizeObserver = original + } + }) + it('keeps one readable text copy while its visual track moves', () => { render() diff --git a/src/renderer/src/OverflowMarquee.tsx b/src/renderer/src/OverflowMarquee.tsx index 874a705f..33cc7ae1 100644 --- a/src/renderer/src/OverflowMarquee.tsx +++ b/src/renderer/src/OverflowMarquee.tsx @@ -54,7 +54,6 @@ export function OverflowMarquee({ }, []) useLayoutEffect(() => { - measureOverflow() const container = containerRef.current const track = trackRef.current if ( @@ -62,11 +61,15 @@ export function OverflowMarquee({ !track || typeof ResizeObserver !== 'function' ) { + measureOverflow() window.addEventListener('resize', measureOverflow) return () => window.removeEventListener('resize', measureOverflow) } + // The observer reports both elements once after layout (before paint), + // so mounting many rows does not force a synchronous layout per row. A + // text change re-creates the observer, whose first report re-measures. const observer = new ResizeObserver(measureOverflow) observer.observe(container) observer.observe(track)