diff --git a/apps/readest-app/src/__tests__/app/library/group-header.test.tsx b/apps/readest-app/src/__tests__/app/library/group-header.test.tsx new file mode 100644 index 00000000..7eded996 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/library/group-header.test.tsx @@ -0,0 +1,66 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import GroupHeader from '@/app/library/components/GroupHeader'; +import { LibraryGroupByType } from '@/types/settings'; + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (key: string) => key, +})); + +vi.mock('@/hooks/useResponsiveSize', () => ({ + useResponsiveSize: (n: number) => n, +})); + +const routerStub = { push: vi.fn(), replace: vi.fn(), back: vi.fn() }; +let currentSearch = ''; +vi.mock('next/navigation', () => ({ + useRouter: () => routerStub, + useSearchParams: () => new URLSearchParams(currentSearch), +})); + +const navigateToLibraryMock = vi.fn(); +vi.mock('@/utils/nav', () => ({ + navigateToLibrary: (...args: unknown[]) => navigateToLibraryMock(...args), +})); + +afterEach(() => { + cleanup(); + navigateToLibraryMock.mockReset(); +}); + +describe('GroupHeader back button', () => { + // Regression for #4437: inside a series/author folder after a cold start, the + // URL is just `?group=X` (groupBy comes from settings, not the URL). Deleting + // `group` would leave an empty query, and `router.replace('/library')` with an + // empty search silently no-ops under the Next.js 16.2 static export (same root + // cause as #3782, fixed for the breadcrumb "All" button in #3832). The back + // button must keep the query non-empty via the `group=` workaround so the + // navigation actually commits. + it('keeps a non-empty query when group is the only param', () => { + currentSearch = 'group=abc123'; + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Back to library' })); + + expect(navigateToLibraryMock).toHaveBeenCalledTimes(1); + const query = navigateToLibraryMock.mock.calls[0]![1] as string; + expect(query).not.toBe(''); + const params = new URLSearchParams(query); + expect(params.has('group')).toBe(true); + expect(params.get('group')).toBe(''); + }); + + it('preserves other params while clearing the group', () => { + currentSearch = 'groupBy=author&sort=title&group=abc123'; + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Back to library' })); + + const query = navigateToLibraryMock.mock.calls[0]![1] as string; + const params = new URLSearchParams(query); + expect(params.get('groupBy')).toBe('author'); + expect(params.get('sort')).toBe('title'); + expect(params.get('group')).toBe(''); + }); +}); diff --git a/apps/readest-app/src/app/library/components/GroupHeader.tsx b/apps/readest-app/src/app/library/components/GroupHeader.tsx index 77930cfc..87f0509d 100644 --- a/apps/readest-app/src/app/library/components/GroupHeader.tsx +++ b/apps/readest-app/src/app/library/components/GroupHeader.tsx @@ -23,7 +23,16 @@ const GroupHeader: React.FC = ({ groupBy, groupName }) => { const handleBack = () => { const params = new URLSearchParams(searchParams?.toString()); - params.delete('group'); + // Set `group` to an empty string instead of deleting it. After a cold start + // the URL inside a series/author folder is just `?group=X` (groupBy comes + // from settings, not the URL), so deleting `group` would leave an empty + // search string — and `router.replace('/library')` with an empty search + // silently no-ops under the Next.js 16.2 static export, leaving the back + // button dead (#4437). This mirrors the workaround in + // `handleLibraryNavigation` (see page.tsx, originally #3782/#3832): the + // resulting `/library?group=` does commit, and the trailing empty `group=` + // is stripped cosmetically by the cleanup effect in page.tsx. + params.set('group', ''); navigateToLibrary(router, params.toString()); };