diff --git a/frontend/src/layout/layout.tsx b/frontend/src/layout/layout.tsx
index b3eaaf78b..fa62f9869 100644
--- a/frontend/src/layout/layout.tsx
+++ b/frontend/src/layout/layout.tsx
@@ -22,7 +22,10 @@ const Layout: React.FC = () => {
{/* Contain scrolling here so Navbar's parent height never exceeds 100vh — now the navbar is stuck it will not go away.
hide-scrollbar keeps overflow-y-auto (needed for the sticky navbar) from painting a second, default scrollbar on Windows/WebView2. */}
-
+
diff --git a/frontend/src/pages/SearchResults/SearchResults.tsx b/frontend/src/pages/SearchResults/SearchResults.tsx
index a4387dff0..ad0798672 100644
--- a/frontend/src/pages/SearchResults/SearchResults.tsx
+++ b/frontend/src/pages/SearchResults/SearchResults.tsx
@@ -94,6 +94,11 @@ export const SearchResults = () => {
}
const currentSearchGeneration = searchGenerationRef.current;
+ // Reset the scroll position to the top whenever the search changes
+ useEffect(() => {
+ document.getElementById('main-scroll-container')?.scrollTo({ top: 0 });
+ }, [query, mode]);
+
const { data: statusData, isSuccess: isStatusSuccess } = usePictoQuery({
queryKey: ['models', 'status'],
queryFn: fetchModelStatus,
diff --git a/frontend/src/pages/__tests__/SearchResults.test.tsx b/frontend/src/pages/__tests__/SearchResults.test.tsx
index 64d70dc35..98d98dc34 100644
--- a/frontend/src/pages/__tests__/SearchResults.test.tsx
+++ b/frontend/src/pages/__tests__/SearchResults.test.tsx
@@ -1,4 +1,5 @@
-import { render, screen, waitFor } from '@/test-utils';
+import { render, screen, waitFor, fireEvent } from '@/test-utils';
+import { useNavigate } from 'react-router';
import { SearchResults } from '../SearchResults/SearchResults';
import {
searchImagesByTag,
@@ -373,4 +374,84 @@ describe('SearchResults Page', () => {
{ timeout: 3000 },
);
});
+
+ test('resets the main scroll container to the top on a new search', async () => {
+ // The real scroll container lives in the layout, which this page-level
+ // test does not mount. Stand one in and give its scrollTo the same
+ // observable effect a browser has -- it updates scrollTop -- so the test
+ // can assert the user-visible scroll state rather than a spy call.
+ const scrollContainer = document.createElement('div');
+ scrollContainer.id = 'main-scroll-container';
+ const scrollTo = jest.fn((options?: ScrollToOptions) => {
+ if (typeof options?.top === 'number') {
+ scrollContainer.scrollTop = options.top;
+ }
+ });
+ // Define the property directly instead of casting a jest.fn onto the
+ // read-only overloaded HTMLElement['scrollTo'] signature.
+ Object.defineProperty(scrollContainer, 'scrollTo', {
+ value: scrollTo,
+ writable: true,
+ configurable: true,
+ });
+ document.body.appendChild(scrollContainer);
+
+ // Navigate within the same mounted SearchResults so the transition mirrors
+ // a real new search (query param changes, component stays mounted) rather
+ // than a fresh mount whose effect would trivially fire once.
+ const GoToSearch = ({ to }: { to: string }) => {
+ const navigate = useNavigate();
+ return (
+
+ );
+ };
+
+ try {
+ (searchImagesByTag as jest.Mock).mockResolvedValue({
+ success: true,
+ data: [
+ {
+ id: '1',
+ path: '/img1.jpg',
+ thumbnailPath: '/thumb1.jpg',
+ tags: ['cat'],
+ },
+ ],
+ });
+
+ render(
+ <>
+
+
+ >,
+ { initialRoutes: ['/search?value=cat'] },
+ );
+
+ // Let the initial search settle so we isolate the new-search transition.
+ await waitFor(() => {
+ expect(screen.getByText('Results for "cat"')).toBeInTheDocument();
+ });
+
+ // Simulate the user having scrolled the previous, longer result set to
+ // the bottom, then start observing from a clean slate.
+ scrollContainer.scrollTop = 500;
+ scrollTo.mockClear();
+
+ // Run the new search on the still-mounted component.
+ fireEvent.click(screen.getByRole('button', { name: /motorcycle/i }));
+
+ await waitFor(() => {
+ expect(
+ screen.getByText('Results for "motorcycle"'),
+ ).toBeInTheDocument();
+ // The user-visible result: the viewport is back at the top.
+ expect(scrollContainer.scrollTop).toBe(0);
+ });
+ expect(scrollTo).toHaveBeenCalledWith({ top: 0 });
+ } finally {
+ document.body.removeChild(scrollContainer);
+ }
+ });
});