From c47f2c26ed6ea9c6a7c60bd5220869e5d1bcfb10 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 13:16:57 +0000 Subject: [PATCH] fix(tasks): adaptive visibility-aware polling for cloud task list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cloud task list polled every 30s on a fixed timer regardless of window visibility, and the global 5-minute staleTime caused the implicit refetchOnWindowFocus path to silently skip the refetch when returning within that window — so tasks created elsewhere (e.g. on mobile while away from desktop) never appeared until the user manually reloaded. Add a visibility-aware adaptive poll in useTasks / useTaskSummaries / useSlackTasks: start at 30s while the window is focused, exponentially back off to a 3-minute ceiling, pause entirely while blurred or hidden, and invalidate the list immediately on focus return so the refresh happens explicitly rather than going through TanStack's staleness gate. Add useTasks.test.tsx covering the polling cadence, pause-on-hidden, the focus-return invalidate, and that the initial mount does not trigger an extra invalidate. Refs #2335. Generated-By: PostHog Code Task-Id: 0707617f-64d9-4a1b-b45a-b2e22e5cfc25 --- .../features/tasks/hooks/useTasks.test.tsx | 180 ++++++++++++++++++ .../renderer/features/tasks/hooks/useTasks.ts | 65 ++++++- 2 files changed, 237 insertions(+), 8 deletions(-) create mode 100644 apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx 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, }, ); }