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());
};