diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx b/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx new file mode 100644 index 000000000..5f29872e0 --- /dev/null +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx @@ -0,0 +1,180 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockInvalidateQueries = vi.hoisted(() => vi.fn()); +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }), + keepPreviousData: Symbol("keepPreviousData"), +})); + +const captured = vi.hoisted(() => ({ + value: null as { + queryKey: unknown; + options: Record; + } | null, +})); + +vi.mock("@hooks/useAuthenticatedQuery", () => ({ + useAuthenticatedQuery: ( + queryKey: unknown, + _queryFn: unknown, + options: Record, + ) => { + captured.value = { queryKey, options }; + return { data: [], isLoading: false }; + }, +})); + +vi.mock("@hooks/useAuthenticatedMutation", () => ({ + useAuthenticatedMutation: () => ({ mutateAsync: vi.fn(), mutate: vi.fn() }), +})); + +vi.mock("@hooks/useMeQuery", () => ({ + useMeQuery: () => ({ data: { id: 42 } }), +})); + +vi.mock("@features/sidebar/hooks/usePinnedTasks", () => ({ + pinnedTasksApi: { unpin: vi.fn() }, +})); + +vi.mock("@features/workspace/hooks/useWorkspace", () => ({ + workspaceApi: { get: vi.fn(), delete: vi.fn() }, +})); + +vi.mock("@renderer/stores/focusStore", () => ({ + useFocusStore: { getState: () => ({ session: null, disableFocus: vi.fn() }) }, +})); + +vi.mock("@renderer/stores/navigationStore", () => ({ + useNavigationStore: () => ({ + view: { type: "task-input" }, + navigateToTaskInput: vi.fn(), + }), +})); + +vi.mock("@renderer/trpc/client", () => ({ + trpcClient: { + contextMenu: { confirmDeleteTask: { mutate: vi.fn() } }, + }, +})); + +vi.mock("@utils/logger", () => ({ + logger: { + scope: () => ({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, +})); + +import { + TASK_LIST_POLL_MAX_MS, + TASK_LIST_POLL_MIN_MS, + useTasks, +} from "./useTasks"; + +type IntervalFn = () => number | false; + +function getRefetchInterval(): IntervalFn { + const interval = captured.value?.options.refetchInterval; + if (typeof interval !== "function") { + throw new Error("refetchInterval was not a function"); + } + return interval as IntervalFn; +} + +let hasFocusSpy: ReturnType; + +function setFocused(focused: boolean): void { + act(() => { + hasFocusSpy.mockReturnValue(focused); + window.dispatchEvent(new Event(focused ? "focus" : "blur")); + }); +} + +describe("useTasks polling", () => { + beforeEach(() => { + hasFocusSpy = vi.spyOn(document, "hasFocus").mockReturnValue(true); + window.dispatchEvent(new Event("focus")); + mockInvalidateQueries.mockReset(); + captured.value = null; + }); + + afterEach(() => { + hasFocusSpy.mockRestore(); + }); + + it("starts polling at the minimum interval while focused", () => { + renderHook(() => useTasks()); + expect(getRefetchInterval()()).toBe(TASK_LIST_POLL_MIN_MS); + }); + + it("exponentially backs off up to the maximum interval", () => { + renderHook(() => useTasks()); + + const refetchInterval = getRefetchInterval(); + const seen = [ + refetchInterval(), + refetchInterval(), + refetchInterval(), + refetchInterval(), + refetchInterval(), + ]; + + expect(seen).toEqual([ + TASK_LIST_POLL_MIN_MS, + TASK_LIST_POLL_MIN_MS * 2, + TASK_LIST_POLL_MIN_MS * 4, + // 30s * 8 = 240s, clamped to 180s + TASK_LIST_POLL_MAX_MS, + TASK_LIST_POLL_MAX_MS, + ]); + }); + + it("pauses polling when the window blurs", () => { + const { rerender } = renderHook(() => useTasks()); + + setFocused(false); + rerender(); + + const refetchInterval = getRefetchInterval(); + expect(refetchInterval()).toBe(false); + expect(refetchInterval()).toBe(false); + }); + + it("resets backoff and invalidates the list on focus return", () => { + const { rerender } = renderHook(() => useTasks()); + + let refetchInterval = getRefetchInterval(); + refetchInterval(); + refetchInterval(); + refetchInterval(); + + const queryKey = captured.value?.queryKey; + + setFocused(false); + rerender(); + expect(mockInvalidateQueries).not.toHaveBeenCalled(); + + setFocused(true); + rerender(); + + expect(mockInvalidateQueries).toHaveBeenCalledTimes(1); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey }); + + refetchInterval = getRefetchInterval(); + expect(refetchInterval()).toBe(TASK_LIST_POLL_MIN_MS); + }); + + it("does not invalidate on the initial focused mount", () => { + renderHook(() => useTasks()); + expect(mockInvalidateQueries).not.toHaveBeenCalled(); + }); + + it("disables background polling so the query doesn't fire while hidden", () => { + renderHook(() => useTasks()); + expect(captured.value?.options.refetchIntervalInBackground).toBe(false); + }); +}); diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts index 9643f8ccf..ef1d7c8cc 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts @@ -6,15 +6,48 @@ import { useMeQuery } from "@hooks/useMeQuery"; import type { Schemas } from "@renderer/api/generated"; import { useFocusStore } from "@renderer/stores/focusStore"; import { useNavigationStore } from "@renderer/stores/navigationStore"; +import { useRendererWindowFocusStore } from "@renderer/stores/rendererWindowFocusStore"; import { trpcClient } from "@renderer/trpc/client"; import type { Task } from "@shared/types"; import { keepPreviousData, useQueryClient } from "@tanstack/react-query"; import { logger } from "@utils/logger"; -import { useCallback } from "react"; +import { useCallback, useEffect, useRef } from "react"; const log = logger.scope("tasks"); -const TASK_LIST_POLL_INTERVAL_MS = 30_000; +// Polling resets to MIN on focus return, doubles toward MAX while focused, and +// pauses entirely while blurred. The 5-minute global staleTime means +// refetchOnWindowFocus is unreliable for surfacing tasks created elsewhere +// (e.g. on mobile), so we drive the refresh on focus explicitly. +export const TASK_LIST_POLL_MIN_MS = 30_000; +export const TASK_LIST_POLL_MAX_MS = 3 * 60_000; + +function useAdaptiveTaskListPolling( + queryKey: readonly unknown[], +): () => number | false { + const focused = useRendererWindowFocusStore((s) => s.focused); + const queryClient = useQueryClient(); + const intervalRef = useRef(TASK_LIST_POLL_MIN_MS); + const previousFocusedRef = useRef(focused); + const queryKeyRef = useRef(queryKey); + queryKeyRef.current = queryKey; + + useEffect(() => { + const wasFocused = previousFocusedRef.current; + previousFocusedRef.current = focused; + if (focused && !wasFocused) { + intervalRef.current = TASK_LIST_POLL_MIN_MS; + queryClient.invalidateQueries({ queryKey: queryKeyRef.current }); + } + }, [focused, queryClient]); + + return useCallback((): number | false => { + if (!focused) return false; + const next = intervalRef.current; + intervalRef.current = Math.min(next * 2, TASK_LIST_POLL_MAX_MS); + return next; + }, [focused]); +} const taskKeys = { all: ["tasks"] as const, @@ -43,8 +76,15 @@ export function useTasks( const createdBy = filters?.showAllUsers ? undefined : currentUser?.id; const internal = filters?.showInternal ? true : undefined; + const queryKey = taskKeys.list({ + repository: filters?.repository, + createdBy, + internal, + }); + const refetchInterval = useAdaptiveTaskListPolling(queryKey); + return useAuthenticatedQuery( - taskKeys.list({ repository: filters?.repository, createdBy, internal }), + queryKey, (client) => client.getTasks({ repository: filters?.repository, @@ -53,7 +93,8 @@ export function useTasks( }) as unknown as Promise, { enabled: (options?.enabled ?? true) && !!currentUser?.id, - refetchInterval: TASK_LIST_POLL_INTERVAL_MS, + refetchInterval, + refetchIntervalInBackground: false, }, ); } @@ -62,12 +103,16 @@ export function useTaskSummaries( ids: string[], options?: { enabled?: boolean }, ) { + const queryKey = taskKeys.summaries(ids); + const refetchInterval = useAdaptiveTaskListPolling(queryKey); + return useAuthenticatedQuery( - taskKeys.summaries(ids), + queryKey, (client) => client.getTaskSummaries(ids), { enabled: (options?.enabled ?? true) && ids.length > 0, - refetchInterval: TASK_LIST_POLL_INTERVAL_MS, + refetchInterval, + refetchIntervalInBackground: false, placeholderData: keepPreviousData, }, ); @@ -82,8 +127,11 @@ export function useSlackTasks(options?: { showInternal?: boolean; }) { const internal = options?.showInternal ? true : undefined; + const queryKey = taskKeys.list({ originProduct: "slack", internal }); + const refetchInterval = useAdaptiveTaskListPolling(queryKey); + return useAuthenticatedQuery( - taskKeys.list({ originProduct: "slack", internal }), + queryKey, (client) => client.getTasks({ originProduct: "slack", @@ -91,7 +139,8 @@ export function useSlackTasks(options?: { }) as unknown as Promise, { enabled: options?.enabled ?? true, - refetchInterval: TASK_LIST_POLL_INTERVAL_MS, + refetchInterval, + refetchIntervalInBackground: false, }, ); }