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
5 changes: 4 additions & 1 deletion frontend/src/layout/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ const Layout: React.FC = () => {
<AppSidebar />
{/* 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. */}
<div className="hide-scrollbar m-4 w-full overflow-y-auto">
<div
id="main-scroll-container"
className="hide-scrollbar m-4 w-full overflow-y-auto"
>
<Outlet />
</div>
</div>
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/pages/SearchResults/SearchResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
83 changes: 82 additions & 1 deletion frontend/src/pages/__tests__/SearchResults.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 (
<button type="button" onClick={() => navigate(to)}>
go to {to}
</button>
);
};

try {
(searchImagesByTag as jest.Mock).mockResolvedValue({
success: true,
data: [
{
id: '1',
path: '/img1.jpg',
thumbnailPath: '/thumb1.jpg',
tags: ['cat'],
},
],
});

render(
<>
<GoToSearch to="/search?value=motorcycle" />
<SearchResults />
</>,
{ 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);
}
});
});
Loading