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 { 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" }),
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user