feat(FN-4780): complete Step 4 — cache hydrate useTasks

Fusion-Task-Id: FN-4780
Fusion-Task-Lineage: 7f052539-9d15-4997-8135-21434f065d4a
This commit is contained in:
Fusion (runfusion.ai)
2026-05-16 12:58:05 -07:00
committed by gsxdsm
parent b073f4ab27
commit 508ab73486
2 changed files with 86 additions and 3 deletions

View File

@@ -19,6 +19,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react"; import { renderHook, act, waitFor } from "@testing-library/react";
import { useTasks } from "../useTasks"; import { useTasks } from "../useTasks";
import * as api from "../../api"; import * as api from "../../api";
import * as swrCache from "../../utils/swrCache";
import type { Task, Column } from "@fusion/core"; import type { Task, Column } from "@fusion/core";
// Mock the api module // Mock the api module
@@ -49,6 +50,8 @@ const mockCreateTask = vi.mocked(api.createTask);
const mockDuplicateTask = vi.mocked(api.duplicateTask); const mockDuplicateTask = vi.mocked(api.duplicateTask);
const mockUpdateTask = vi.mocked(api.updateTask); const mockUpdateTask = vi.mocked(api.updateTask);
const mockArchiveAllDone = vi.mocked(api.archiveAllDone); const mockArchiveAllDone = vi.mocked(api.archiveAllDone);
const mockReadCache = vi.spyOn(swrCache, "readCache");
const mockWriteCache = vi.spyOn(swrCache, "writeCache");
// Mock EventSource // Mock EventSource
class MockEventSource { class MockEventSource {
@@ -91,6 +94,9 @@ beforeEach(() => {
MockEventSource.instances = []; MockEventSource.instances = [];
(globalThis as any).EventSource = MockEventSource; (globalThis as any).EventSource = MockEventSource;
mockFetchTasks.mockReset().mockResolvedValue([]); mockFetchTasks.mockReset().mockResolvedValue([]);
mockReadCache.mockReset();
mockWriteCache.mockReset();
mockReadCache.mockReturnValue(null);
// Ensure we start with real timers for every test // Ensure we start with real timers for every test
vi.useRealTimers(); vi.useRealTimers();
@@ -140,6 +146,56 @@ describe("useTasks", () => {
expect(result.current.tasks[0].id).toBe("FN-001"); expect(result.current.tasks[0].id).toBe("FN-001");
}); });
it("hydrates per-project cached tasks synchronously", () => {
mockReadCache.mockReturnValueOnce([createMockTask({ id: "FN-CACHED" })]);
const { result } = renderHook(() => useTasks({ projectId: "proj-1" }));
expect(result.current.tasks[0]?.id).toBe("FN-CACHED");
expect(result.current.isStale).toBe(true);
});
it("isStale flips false after successful fetch", async () => {
mockFetchTasks.mockResolvedValueOnce([createMockTask({ id: "FN-LIVE" })]);
const { result } = renderHook(() => useTasks({ projectId: "proj-1" }));
expect(result.current.isStale).toBe(true);
await waitFor(() => {
expect(result.current.isStale).toBe(false);
});
});
it("writes through task cache on successful fetch", async () => {
mockFetchTasks.mockResolvedValueOnce([createMockTask({ id: "FN-LIVE" })]);
renderHook(() => useTasks({ projectId: "proj-1" }));
await waitFor(() => {
expect(mockWriteCache).toHaveBeenCalledWith(
`${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-1`,
expect.any(Array),
{ maxBytes: 500_000 },
);
});
});
it("caps task cache writes to first 500 entries", async () => {
const manyTasks = Array.from({ length: 550 }, (_, index) =>
createMockTask({ id: `FN-${index.toString().padStart(3, "0")}` }),
);
mockFetchTasks.mockResolvedValueOnce(manyTasks);
renderHook(() => useTasks({ projectId: "proj-1" }));
await waitFor(() => {
expect(mockWriteCache).toHaveBeenCalled();
});
const writePayload = mockWriteCache.mock.calls.at(-1)?.[1] as Task[];
expect(writePayload).toHaveLength(500);
});
it("normalizes invalid column values from initial fetch to triage", async () => { it("normalizes invalid column values from initial fetch to triage", async () => {
const malformedTask = { const malformedTask = {
...createMockTask({ id: "FN-099" }), ...createMockTask({ id: "FN-099" }),

View File

@@ -3,6 +3,7 @@ import type { Task, Column, TaskCreateInput, MergeResult, GithubIssueAction } fr
import { normalizeColumn } from "@fusion/core"; import { normalizeColumn } from "@fusion/core";
import * as api from "../api"; import * as api from "../api";
import { subscribeSse } from "../sse-bus"; import { subscribeSse } from "../sse-bus";
import { readCache, SWR_CACHE_KEYS, writeCache } from "../utils/swrCache";
function normalizeTask(task: Task): Task { function normalizeTask(task: Task): Task {
return { return {
@@ -92,7 +93,14 @@ export function useTasks(options?: UseTasksOptions) {
const projectId = options?.projectId; const projectId = options?.projectId;
const searchQuery = options?.searchQuery; const searchQuery = options?.searchQuery;
const sseEnabled = options?.sseEnabled ?? true; const sseEnabled = options?.sseEnabled ?? true;
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>(() => {
if (!projectId) {
return [];
}
const cachedTasks = readCache<Task[]>(`${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`);
return Array.isArray(cachedTasks) ? cachedTasks.map(normalizeTask) : [];
});
const [isStale, setIsStale] = useState(true);
// Once the user expands the archived column, we keep including archived tasks // Once the user expands the archived column, we keep including archived tasks
// in subsequent refreshes for the lifetime of this hook instance. // in subsequent refreshes for the lifetime of this hook instance.
const [includeArchived, setIncludeArchived] = useState(false); const [includeArchived, setIncludeArchived] = useState(false);
@@ -139,7 +147,13 @@ export function useTasks(options?: UseTasksOptions) {
if (fetchVersionRef.current !== requestVersion || projectId !== requestProjectId) { if (fetchVersionRef.current !== requestVersion || projectId !== requestProjectId) {
return; return;
} }
setTasks(fetchedTasks.map(normalizeTask)); const normalizedFetchedTasks = fetchedTasks.map(normalizeTask);
setTasks(normalizedFetchedTasks);
if (requestProjectId) {
const cachedPayload = fetchedTasks.length > 500 ? fetchedTasks.slice(0, 500) : fetchedTasks;
writeCache(`${SWR_CACHE_KEYS.TASKS_PREFIX}${requestProjectId}`, cachedPayload, { maxBytes: 500_000 });
}
setIsStale(false);
// Record when we received fresh server data for stuck detection // Record when we received fresh server data for stuck detection
lastFetchTimeMs.current = Date.now(); lastFetchTimeMs.current = Date.now();
} catch { } catch {
@@ -178,8 +192,21 @@ export function useTasks(options?: UseTasksOptions) {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchQuery]); // intentionally NOT including refreshTasks in deps }, [searchQuery]); // intentionally NOT including refreshTasks in deps
useEffect(() => {
if (!projectId) {
return;
}
const cachedTasks = readCache<Task[]>(`${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`);
if (Array.isArray(cachedTasks)) {
setTasks(cachedTasks.map(normalizeTask));
}
setIsStale(true);
}, [projectId]);
// Fetch initial tasks and recover when the tab becomes visible again. // Fetch initial tasks and recover when the tab becomes visible again.
useEffect(() => { useEffect(() => {
setIsStale(true);
void refreshTasks({ clearOnError: true }); void refreshTasks({ clearOnError: true });
const handleVisibilityChange = () => { const handleVisibilityChange = () => {
@@ -499,5 +526,5 @@ export function useTasks(options?: UseTasksOptions) {
lastFetchTimeMs.current = Date.now(); lastFetchTimeMs.current = Date.now();
}, []); }, []);
return { tasks, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, resetTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, includeArchived, refreshTasks, ingestCreatedTasks, lastFetchTimeMs: lastFetchTimeMs.current }; return { tasks, isStale, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, resetTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, includeArchived, refreshTasks, ingestCreatedTasks, lastFetchTimeMs: lastFetchTimeMs.current };
} }