diff --git a/src/components/ui/ScrollArea/fragments/ScrollAreaRoot.tsx b/src/components/ui/ScrollArea/fragments/ScrollAreaRoot.tsx index 39668dbad..41d7a0ff0 100644 --- a/src/components/ui/ScrollArea/fragments/ScrollAreaRoot.tsx +++ b/src/components/ui/ScrollArea/fragments/ScrollAreaRoot.tsx @@ -53,16 +53,42 @@ const ScrollAreaRoot = forwardRef(({ }; const resizeObserver = new ResizeObserver(() => handleResize()); - resizeObserver.observe(viewport); - Array.from(viewport.children).forEach(child => resizeObserver.observe(child)); + const syncResizeObservers = () => { + resizeObserver.disconnect(); + resizeObserver.observe(viewport); + Array.from(viewport.children).forEach(child => { + if (child instanceof Element) { + resizeObserver.observe(child); + } + }); + }; + + syncResizeObservers(); + + const mutationObserver = new MutationObserver((mutations) => { + const directChildSwap = mutations.some( + (mutation) => + mutation.type === 'childList' + && mutation.target === viewport + && (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0) + ); + + if (directChildSwap) { + viewport.scrollTop = 0; + viewport.scrollLeft = 0; + syncResizeObservers(); + } - const mutationObserver = new MutationObserver(() => { handleResize(); + + if (directChildSwap) { + handleScroll(); + } }); mutationObserver.observe(viewport, { childList: true, - subtree: true + subtree: false }); window.addEventListener('resize', handleResize); diff --git a/src/components/ui/ScrollArea/tests/ScrollArea.test.tsx b/src/components/ui/ScrollArea/tests/ScrollArea.test.tsx index cc50437a6..209c27b39 100644 --- a/src/components/ui/ScrollArea/tests/ScrollArea.test.tsx +++ b/src/components/ui/ScrollArea/tests/ScrollArea.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import ScrollArea from '../ScrollArea'; @@ -176,4 +176,42 @@ describe('ScrollArea', () => { expect(screen.getByTestId('scrollbar')).toHaveAttribute('data-state', 'visible'); }); + + test('resets scroll when direct viewport child is swapped, not on nested mutations', async() => { + const { rerender } = render( + + +
Panel A
+
+
+ ); + + const viewport = screen.getByTestId('viewport') as HTMLDivElement; + viewport.scrollTop = 120; + + act(() => { + const nested = document.createElement('span'); + nested.textContent = 'nested'; + screen.getByTestId('panel-a').appendChild(nested); + }); + + expect(viewport.scrollTop).toBe(120); + + rerender( + + +
Panel B
+
+
+ ); + + await act(async() => { + await Promise.resolve(); + }); + + const nextViewport = screen.getByTestId('viewport') as HTMLDivElement; + await waitFor(() => { + expect(nextViewport.scrollTop).toBe(0); + }); + }); });