From b9cfdb93237ee08fd87450e1aa4dce887bf34d48 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 31 May 2026 18:29:35 +0300 Subject: [PATCH] fix(team-messages): scope debounced sidebar scroll persistence to its team A scroll within the 100ms persist debounce could otherwise have its queued update fire after the user switched teams, writing the previous team's offset under the new team and restoring the sidebar to the wrong position. Capture the team that scheduled the update and drop it on fire if the active team changed. --- .../team/messages/MessagesPanel.tsx | 10 ++ .../team/messages/MessagesPanel.test.ts | 91 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 7356deaf..24397856 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -507,6 +507,9 @@ export const MessagesPanel = memo(function MessagesPanel({ ); const messagesScrollTopRef = useRef(initialSidebarStateRef.current.messagesScrollTop); const messagesScrollPersistTimerRef = useRef | null>(null); + // Tracks which team the pending scroll persistence belongs to, so a debounced update + // scheduled before a team switch is never applied to or persisted under the new team. + const messagesScrollPersistTeamRef = useRef(teamName); const [bottomSheetSnapIndex, setBottomSheetSnapIndex] = useState( initialSidebarStateRef.current.bottomSheetSnapIndex ); @@ -522,6 +525,7 @@ export const MessagesPanel = memo(function MessagesPanel({ setMessagesSearchBarVisible(initialSidebarStateRef.current.messagesSearchBarVisible); setExpandedItemKey(initialSidebarStateRef.current.expandedItemKey); messagesScrollTopRef.current = initialSidebarStateRef.current.messagesScrollTop; + messagesScrollPersistTeamRef.current = teamName; setMessagesScrollTop(initialSidebarStateRef.current.messagesScrollTop); setBottomSheetSnapIndex(initialSidebarStateRef.current.bottomSheetSnapIndex); }, [teamName]); @@ -551,11 +555,17 @@ export const MessagesPanel = memo(function MessagesPanel({ const persistMessagesScrollTop = useCallback((nextScrollTop: number): void => { messagesScrollTopRef.current = nextScrollTop; + const scheduledTeamName = messagesScrollPersistTeamRef.current; if (messagesScrollPersistTimerRef.current) { clearTimeout(messagesScrollPersistTimerRef.current); } messagesScrollPersistTimerRef.current = setTimeout(() => { messagesScrollPersistTimerRef.current = null; + // Drop a queued update that outlived a team switch: it carries the previous team's + // offset and must not overwrite the scroll state the new team just restored. + if (messagesScrollPersistTeamRef.current !== scheduledTeamName) { + return; + } setMessagesScrollTop((current) => Math.abs(current - messagesScrollTopRef.current) < 1 ? current diff --git a/test/renderer/components/team/messages/MessagesPanel.test.ts b/test/renderer/components/team/messages/MessagesPanel.test.ts index 3de4e687..d380b834 100644 --- a/test/renderer/components/team/messages/MessagesPanel.test.ts +++ b/test/renderer/components/team/messages/MessagesPanel.test.ts @@ -570,6 +570,97 @@ describe('MessagesPanel idle summary invariants', () => { ); }); + it('flushes a pending scroll to the previous team without leaking it when switching teams mid-debounce', async () => { + vi.useFakeTimers(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + const messagesState = { + canonicalMessages: [makeMessage({ messageId: 'm-1', text: 'hello' })], + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: null, + hasMore: false, + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }; + + await act(async () => { + storeState.teamMessagesByName['atlas-hq'] = messagesState; + storeState.teamMessagesByName['beta-hq'] = messagesState; + root.render( + React.createElement(MessagesPanel, { + teamName: 'atlas-hq', + position: 'sidebar', + onPositionChange: vi.fn(), + members: [], + tasks: [], + timeWindow: null, + pendingRepliesByMember: {}, + onPendingReplyChange: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const scrollContainer = host.querySelector('.overflow-y-auto') as HTMLDivElement | null; + expect(scrollContainer).not.toBeNull(); + + // Scroll, leaving the 100ms persist debounce pending (do not advance timers yet). + await act(async () => { + scrollContainer!.scrollTop = 320; + scrollContainer!.dispatchEvent(new Event('scroll', { bubbles: true })); + await Promise.resolve(); + }); + + vi.mocked(setTeamMessagesSidebarUiState).mockClear(); + + // Switch teams while the debounced scroll update is still queued. + await act(async () => { + root.render( + React.createElement(MessagesPanel, { + teamName: 'beta-hq', + position: 'sidebar', + onPositionChange: vi.fn(), + members: [], + tasks: [], + timeWindow: null, + pendingRepliesByMember: {}, + onPendingReplyChange: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + await act(async () => { + vi.advanceTimersByTime(100); + await Promise.resolve(); + }); + + // The pending offset is flushed to the team that actually owned it... + expect(setTeamMessagesSidebarUiState).toHaveBeenCalledWith( + 'atlas-hq', + expect.objectContaining({ messagesScrollTop: 320 }) + ); + // ...and is never persisted under the team we switched into. + const leakedToNewTeam = vi + .mocked(setTeamMessagesSidebarUiState) + .mock.calls.some( + ([name, state]) => + name === 'beta-hq' && (state as { messagesScrollTop?: number }).messagesScrollTop === 320 + ); + expect(leakedToNewTeam).toBe(false); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('hides passive peer summaries by default while unread badge only counts filtered unread messages', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div');