Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions src/components/ui/ScrollArea/fragments/ScrollAreaRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,42 @@ const ScrollAreaRoot = forwardRef<ScrollAreaRootElement, ScrollAreaRootProps>(({
};

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)
);
Comment on lines +69 to +74

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Narrow the reset predicate to actual child replacements.

directChildSwap currently fires for any direct child add/remove, so appending or removing an item directly under the viewport will reset the user to the top. Track added and removed nodes across the batch and reset only when both occur for the viewport.

🐛 Proposed predicate tightening
-            const directChildSwap = mutations.some(
-                (mutation) =>
-                    mutation.type === 'childList'
-                    && mutation.target === viewport
-                    && (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)
-            );
+            const viewportChildMutations = mutations.filter(
+                mutation => mutation.type === 'childList' && mutation.target === viewport
+            );
+            const directChildSwap =
+                viewportChildMutations.some(mutation => mutation.addedNodes.length > 0)
+                && viewportChildMutations.some(mutation => mutation.removedNodes.length > 0);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const directChildSwap = mutations.some(
(mutation) =>
mutation.type === 'childList'
&& mutation.target === viewport
&& (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)
);
const viewportChildMutations = mutations.filter(
mutation => mutation.type === 'childList' && mutation.target === viewport
);
const directChildSwap =
viewportChildMutations.some(mutation => mutation.addedNodes.length > 0)
&& viewportChildMutations.some(mutation => mutation.removedNodes.length > 0);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ui/ScrollArea/fragments/ScrollAreaRoot.tsx` around lines 69 -
74, The direct reset predicate in ScrollAreaRoot is too broad because it treats
any childList change on the viewport as a swap. Update the mutation handling
around directChildSwap to inspect the full mutations batch and only trigger a
reset when the viewport has both addedNodes and removedNodes, indicating an
actual child replacement. Keep the logic localized to the viewport mutation
check in ScrollAreaRoot.


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);
Expand Down
40 changes: 39 additions & 1 deletion src/components/ui/ScrollArea/tests/ScrollArea.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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(
<ScrollArea.Root style={{ height: 100 }}>
<ScrollArea.Viewport data-testid="viewport" style={{ height: 100, overflow: 'auto' }}>
<div key="panel-a" data-testid="panel-a" style={{ height: 400 }}>Panel A</div>
</ScrollArea.Viewport>
</ScrollArea.Root>
);

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);
Comment on lines +192 to +198

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Flush the nested mutation before the negative assertion.

MutationObserver callbacks are async, so Line 198 can pass before a broken nested-mutation reset runs. Await a microtask after appending the nested node, then assert the scroll position.

💚 Proposed test hardening
         act(() => {
             const nested = document.createElement('span');
             nested.textContent = 'nested';
             screen.getByTestId('panel-a').appendChild(nested);
         });
 
+        await act(async() => {
+            await Promise.resolve();
+        });
+
         expect(viewport.scrollTop).toBe(120);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
act(() => {
const nested = document.createElement('span');
nested.textContent = 'nested';
screen.getByTestId('panel-a').appendChild(nested);
});
expect(viewport.scrollTop).toBe(120);
act(() => {
const nested = document.createElement('span');
nested.textContent = 'nested';
screen.getByTestId('panel-a').appendChild(nested);
});
await act(async() => {
await Promise.resolve();
});
expect(viewport.scrollTop).toBe(120);


rerender(
<ScrollArea.Root style={{ height: 100 }}>
<ScrollArea.Viewport data-testid="viewport" style={{ height: 100, overflow: 'auto' }}>
<div key="panel-b" data-testid="panel-b" style={{ height: 400 }}>Panel B</div>
</ScrollArea.Viewport>
</ScrollArea.Root>
);

await act(async() => {
await Promise.resolve();
});

const nextViewport = screen.getByTestId('viewport') as HTMLDivElement;
await waitFor(() => {
expect(nextViewport.scrollTop).toBe(0);
});
});
});
Loading