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:
committed by
gsxdsm
parent
b073f4ab27
commit
508ab73486
@@ -19,6 +19,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useTasks } from "../useTasks";
|
||||
import * as api from "../../api";
|
||||
import * as swrCache from "../../utils/swrCache";
|
||||
import type { Task, Column } from "@fusion/core";
|
||||
|
||||
// Mock the api module
|
||||
@@ -49,6 +50,8 @@ const mockCreateTask = vi.mocked(api.createTask);
|
||||
const mockDuplicateTask = vi.mocked(api.duplicateTask);
|
||||
const mockUpdateTask = vi.mocked(api.updateTask);
|
||||
const mockArchiveAllDone = vi.mocked(api.archiveAllDone);
|
||||
const mockReadCache = vi.spyOn(swrCache, "readCache");
|
||||
const mockWriteCache = vi.spyOn(swrCache, "writeCache");
|
||||
|
||||
// Mock EventSource
|
||||
class MockEventSource {
|
||||
@@ -91,6 +94,9 @@ beforeEach(() => {
|
||||
MockEventSource.instances = [];
|
||||
(globalThis as any).EventSource = MockEventSource;
|
||||
mockFetchTasks.mockReset().mockResolvedValue([]);
|
||||
mockReadCache.mockReset();
|
||||
mockWriteCache.mockReset();
|
||||
mockReadCache.mockReturnValue(null);
|
||||
|
||||
// Ensure we start with real timers for every test
|
||||
vi.useRealTimers();
|
||||
@@ -140,6 +146,56 @@ describe("useTasks", () => {
|
||||
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 () => {
|
||||
const malformedTask = {
|
||||
...createMockTask({ id: "FN-099" }),
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Task, Column, TaskCreateInput, MergeResult, GithubIssueAction } fr
|
||||
import { normalizeColumn } from "@fusion/core";
|
||||
import * as api from "../api";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { readCache, SWR_CACHE_KEYS, writeCache } from "../utils/swrCache";
|
||||
|
||||
function normalizeTask(task: Task): Task {
|
||||
return {
|
||||
@@ -92,7 +93,14 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
const projectId = options?.projectId;
|
||||
const searchQuery = options?.searchQuery;
|
||||
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
|
||||
// in subsequent refreshes for the lifetime of this hook instance.
|
||||
const [includeArchived, setIncludeArchived] = useState(false);
|
||||
@@ -139,7 +147,13 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
if (fetchVersionRef.current !== requestVersion || projectId !== requestProjectId) {
|
||||
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
|
||||
lastFetchTimeMs.current = Date.now();
|
||||
} catch {
|
||||
@@ -178,8 +192,21 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [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.
|
||||
useEffect(() => {
|
||||
setIsStale(true);
|
||||
void refreshTasks({ clearOnError: true });
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
@@ -499,5 +526,5 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user