Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
} | null,
}));

vi.mock("@hooks/useAuthenticatedQuery", () => ({
useAuthenticatedQuery: (
queryKey: unknown,
_queryFn: unknown,
options: Record<string, unknown>,
) => {
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<typeof vi.spyOn>;

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);
});
});
65 changes: 57 additions & 8 deletions apps/code/src/renderer/features/tasks/hooks/useTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -53,7 +93,8 @@ export function useTasks(
}) as unknown as Promise<Task[]>,
{
enabled: (options?.enabled ?? true) && !!currentUser?.id,
refetchInterval: TASK_LIST_POLL_INTERVAL_MS,
refetchInterval,
refetchIntervalInBackground: false,
},
);
}
Expand All @@ -62,12 +103,16 @@ export function useTaskSummaries(
ids: string[],
options?: { enabled?: boolean },
) {
const queryKey = taskKeys.summaries(ids);
const refetchInterval = useAdaptiveTaskListPolling(queryKey);

return useAuthenticatedQuery<Schemas.TaskSummary[]>(
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,
},
);
Expand All @@ -82,16 +127,20 @@ 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<Task[]>(
taskKeys.list({ originProduct: "slack", internal }),
queryKey,
(client) =>
client.getTasks({
originProduct: "slack",
internal,
}) as unknown as Promise<Task[]>,
{
enabled: options?.enabled ?? true,
refetchInterval: TASK_LIST_POLL_INTERVAL_MS,
refetchInterval,
refetchIntervalInBackground: false,
},
);
}
Expand Down
Loading