From 540bcd44c16681ced510a671e7cf9a74728ae28a Mon Sep 17 00:00:00 2001
From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com>
Date: Sat, 12 Sep 2026 06:33:08 +0000
Subject: [PATCH 1/2] fix: let users retry loading tasks
---
apps/web/src/app/(authenticated)/tasks/Tasks.tsx | 2 +-
.../components/tasks/TaskCardError.client.test.tsx | 14 ++++++++++++++
apps/web/src/components/tasks/TaskCardError.tsx | 6 +++++-
3 files changed, 20 insertions(+), 2 deletions(-)
create mode 100644 apps/web/src/components/tasks/TaskCardError.client.test.tsx
diff --git a/apps/web/src/app/(authenticated)/tasks/Tasks.tsx b/apps/web/src/app/(authenticated)/tasks/Tasks.tsx
index 6451725111..98d413418e 100644
--- a/apps/web/src/app/(authenticated)/tasks/Tasks.tsx
+++ b/apps/web/src/app/(authenticated)/tasks/Tasks.tsx
@@ -650,7 +650,7 @@ export const Tasks = () => {
) : isError ? (
diff --git a/apps/web/src/components/tasks/TaskCardError.client.test.tsx b/apps/web/src/components/tasks/TaskCardError.client.test.tsx
new file mode 100644
index 0000000000..ed6a5d2a19
--- /dev/null
+++ b/apps/web/src/components/tasks/TaskCardError.client.test.tsx
@@ -0,0 +1,14 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+
+import { TaskCardError } from './TaskCardError';
+
+describe('TaskCardError', () => {
+ it('lets the user retry loading tasks', () => {
+ const onRetry = vi.fn();
+
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
+
+ expect(onRetry).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/web/src/components/tasks/TaskCardError.tsx b/apps/web/src/components/tasks/TaskCardError.tsx
index 6f55666d8d..7d4b47afd1 100644
--- a/apps/web/src/components/tasks/TaskCardError.tsx
+++ b/apps/web/src/components/tasks/TaskCardError.tsx
@@ -4,9 +4,10 @@ import {
EmptyMedia,
EmptyDescription,
CircleX,
+ Button,
} from '@/components/system';
-export function TaskCardError() {
+export function TaskCardError({ onRetry }: { onRetry: () => void }) {
return (
@@ -16,6 +17,9 @@ export function TaskCardError() {
Failed to load tasks.
+
);
From 6b46c20627b8fbda47a70406a44da9fa718e5407 Mon Sep 17 00:00:00 2001
From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com>
Date: Sat, 12 Sep 2026 17:35:32 +0000
Subject: [PATCH 2/2] test: cover the tasks list failure, retry and recovery
wiring
---
.../tasks/Tasks.client.test.tsx | 141 ++++++++++++++++++
1 file changed, 141 insertions(+)
create mode 100644 apps/web/src/app/(authenticated)/tasks/Tasks.client.test.tsx
diff --git a/apps/web/src/app/(authenticated)/tasks/Tasks.client.test.tsx b/apps/web/src/app/(authenticated)/tasks/Tasks.client.test.tsx
new file mode 100644
index 0000000000..faa2472368
--- /dev/null
+++ b/apps/web/src/app/(authenticated)/tasks/Tasks.client.test.tsx
@@ -0,0 +1,141 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+type InfiniteTasksState = {
+ data?: { pages: { tasks: { id: string }[]; nextCursor?: string }[] };
+ isPending: boolean;
+ isError: boolean;
+ hasNextPage: boolean;
+ isFetchingNextPage: boolean;
+};
+
+const mocks = vi.hoisted(() => ({
+ refetch: vi.fn(),
+ fetchNextPage: vi.fn(),
+ deleteMutate: vi.fn(),
+ replace: vi.fn(),
+ push: vi.fn(),
+ state: {} as InfiniteTasksState,
+}));
+
+const loadedPage = {
+ pages: [{ tasks: [{ id: 'task-1' }, { id: 'task-2' }] }],
+};
+
+function setState(next: Partial
) {
+ Object.assign(mocks.state, next);
+}
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ replace: mocks.replace, push: mocks.push }),
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+vi.mock('sonner', () => ({
+ toast: { error: vi.fn(), success: vi.fn() },
+}));
+
+vi.mock('@/hooks/useUser', () => ({
+ useAuthorizedUser: () => ({ userId: 'user-1', isAdmin: false }),
+}));
+
+vi.mock('@/hooks/tasks', () => ({
+ useInfiniteTasks: () => ({
+ ...mocks.state,
+ refetch: mocks.refetch,
+ fetchNextPage: mocks.fetchNextPage,
+ }),
+ useDeleteTasks: () => ({ mutate: mocks.deleteMutate, isPending: false }),
+ useTaskFilterState: () => ({
+ hasSpecificUserFilter: false,
+ hasNonDefaultFilters: false,
+ }),
+}));
+
+vi.mock('@/components/tasks', async () => {
+ const { TaskCardError } = await import('@/components/tasks/TaskCardError');
+
+ return {
+ TaskCardError,
+ TaskFilters: () => ,
+ TaskCard: ({ task }: { task: { id: string } }) => (
+ {task.id}
+ ),
+ TaskBoard: ({ tasks }: { tasks: { id: string }[] }) => (
+ {tasks.length}
+ ),
+ TaskCardSkeleton: () => ,
+ TaskBoardSkeleton: () => ,
+ };
+});
+
+import { Tasks } from './Tasks';
+
+describe('Tasks', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ window.localStorage.clear();
+ setState({
+ data: undefined,
+ isPending: true,
+ isError: false,
+ hasNextPage: false,
+ isFetchingNextPage: false,
+ });
+ });
+
+ it('shows the loading skeleton while the initial query is pending', () => {
+ render();
+
+ expect(screen.getByTestId('task-card-skeleton')).toBeInTheDocument();
+ expect(screen.queryByText('Failed to load tasks.')).not.toBeInTheDocument();
+ });
+
+ it('shows the retry action once the initial load has failed', async () => {
+ setState({ isPending: false, isError: true, data: undefined });
+
+ render();
+
+ expect(
+ await screen.findByText('Failed to load tasks.'),
+ ).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
+ expect(screen.queryAllByTestId('task-card')).toHaveLength(0);
+ });
+
+ it('refetches the tasks query and restores the list when the user retries', async () => {
+ setState({ isPending: false, isError: true, data: undefined });
+
+ const { rerender } = render();
+ const retry = await screen.findByRole('button', { name: 'Retry' });
+
+ // A successful refetch flips the query into its loaded state, exactly as
+ // React Query would after the request succeeds.
+ mocks.refetch.mockImplementation(() => {
+ setState({ isError: false, data: loadedPage });
+ return Promise.resolve();
+ });
+
+ fireEvent.click(retry);
+
+ expect(mocks.refetch).toHaveBeenCalledOnce();
+
+ rerender();
+
+ await waitFor(() =>
+ expect(screen.getAllByTestId('task-card')).toHaveLength(2),
+ );
+ expect(screen.queryByText('Failed to load tasks.')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull();
+ });
+
+ it('keeps already loaded tasks visible when a later refresh fails', async () => {
+ setState({ isPending: false, isError: true, data: loadedPage });
+
+ render();
+
+ await waitFor(() =>
+ expect(screen.getAllByTestId('task-card')).toHaveLength(2),
+ );
+ expect(screen.queryByText('Failed to load tasks.')).not.toBeInTheDocument();
+ });
+});