fix(dashboard): keep previous tasks visible during project switch

Dropping the render-phase setTasks([]) on projectId change avoids the
blank flash and full empty→populated Board reconcile that made project
switches feel like a multi-second hang. The existing fetch/version
guards already reject late responses and stale SSE events, so the
previous project's rows safely stay on screen until the new fetch lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-18 16:57:58 -07:00
parent 0bad6c25a5
commit ee25caeeb4
2 changed files with 39 additions and 22 deletions

View File

@@ -1205,16 +1205,19 @@ describe("useTasks", () => {
}); });
describe("project switching", () => { describe("project switching", () => {
it("clears tasks immediately when switching projects to prevent stale data bleed-through", async () => { it("keeps previous tasks visible while new project's fetch is in flight (stale-while-revalidate)", async () => {
// Project A has tasks // Project A has tasks
const projectATasks = [ const projectATasks = [
createMockTask({ id: "FN-A1", description: "Project A task 1" }), createMockTask({ id: "FN-A1", description: "Project A task 1" }),
createMockTask({ id: "FN-A2", description: "Project A task 2" }), createMockTask({ id: "FN-A2", description: "Project A task 2" }),
]; ];
// Project B fetch is unresolved (simulating slow network) let resolveProjectB: (tasks: Task[]) => void;
const projectBFetchPromise = new Promise<Task[]>((resolve) => {
resolveProjectB = resolve;
});
mockFetchTasks mockFetchTasks
.mockResolvedValueOnce(projectATasks) .mockResolvedValueOnce(projectATasks)
.mockImplementation(() => new Promise(() => {})); // Never resolves .mockImplementationOnce(() => projectBFetchPromise);
// Start with project A // Start with project A
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
@@ -1232,18 +1235,28 @@ describe("useTasks", () => {
undefined, undefined, "project-a", undefined, false undefined, undefined, "project-a", undefined, false
); );
// Switch to project B - should immediately clear tasks // Switch to project B — previous tasks should remain visible until new fetch lands
await act(async () => { await act(async () => {
rerender({ projectId: "project-b" }); rerender({ projectId: "project-b" });
}); });
// Tasks should be cleared immediately (no stale project A tasks visible)
expect(result.current.tasks).toHaveLength(0);
// Project B fetch should be in flight // Project B fetch should be in flight
expect(mockFetchTasks).toHaveBeenLastCalledWith( expect(mockFetchTasks).toHaveBeenLastCalledWith(
undefined, undefined, "project-b", undefined, false undefined, undefined, "project-b", undefined, false
); );
// Previous project's tasks remain visible (SWR) — avoids blank flash
expect(result.current.tasks.map((t) => t.id)).toEqual(["FN-A1", "FN-A2"]);
// Once project B resolves, its tasks replace the stale set
const projectBTasks = [createMockTask({ id: "FN-B1", description: "Project B task" })];
await act(async () => {
resolveProjectB!(projectBTasks);
});
await waitFor(() => {
expect(result.current.tasks.map((t) => t.id)).toEqual(["FN-B1"]);
});
}); });
it("ignores late responses from the previous project after switching", async () => { it("ignores late responses from the previous project after switching", async () => {
@@ -1286,21 +1299,21 @@ describe("useTasks", () => {
expect(MockEventSource.instances).toHaveLength(1); expect(MockEventSource.instances).toHaveLength(1);
}); });
// Project A's fetch has not resolved yet, so tasks start empty
expect(result.current.tasks).toHaveLength(0);
// Switch to project B before project A resolves // Switch to project B before project A resolves
await act(async () => { await act(async () => {
rerender({ projectId: "project-b" }); rerender({ projectId: "project-b" });
}); });
// Tasks should be empty after switch // Project A's fetch resolves late (should be ignored due to projectId mismatch)
expect(result.current.tasks).toHaveLength(0);
// Project A's fetch resolves (should be ignored due to projectId mismatch)
await act(async () => { await act(async () => {
resolveProjectA!(projectATasks); resolveProjectA!(projectATasks);
}); });
// Project A data should NOT appear - projectId mismatch // Project A data should NOT appear — late response from previous project is rejected
expect(result.current.tasks).toHaveLength(0); expect(result.current.tasks.some((t) => t.id === "FN-A1")).toBe(false);
// Now resolve project B's fetch // Now resolve project B's fetch
const projectBTasks = [ const projectBTasks = [
@@ -1320,10 +1333,10 @@ describe("useTasks", () => {
const projectATasks = [ const projectATasks = [
createMockTask({ id: "FN-A1", description: "Project A task" }), createMockTask({ id: "FN-A1", description: "Project A task" }),
]; ];
// Project B fetch never resolves // Project B fetch resolves to an empty list so we can cleanly observe SSE-added tasks
mockFetchTasks mockFetchTasks
.mockResolvedValueOnce(projectATasks) .mockResolvedValueOnce(projectATasks)
.mockImplementation(() => new Promise(() => {})); .mockResolvedValue([]);
// Start with project A // Start with project A
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
@@ -1342,8 +1355,10 @@ describe("useTasks", () => {
rerender({ projectId: "project-b" }); rerender({ projectId: "project-b" });
}); });
// Tasks should be cleared // Wait for project B's (empty) fetch to replace the stale task set
expect(result.current.tasks).toHaveLength(0); await waitFor(() => {
expect(result.current.tasks).toHaveLength(0);
});
// Emit a task:created event from the OLD EventSource (project A) // Emit a task:created event from the OLD EventSource (project A)
const newTaskFromStaleSource = createMockTask({ id: "FN-A2", description: "Should not appear" }); const newTaskFromStaleSource = createMockTask({ id: "FN-A2", description: "Should not appear" });
@@ -1424,7 +1439,7 @@ describe("useTasks", () => {
); );
}); });
it("does not clear tasks when searchQuery changes (only projectId changes trigger clear)", async () => { it("keeps tasks visible when searchQuery changes", async () => {
const initialTasks = [ const initialTasks = [
createMockTask({ id: "FN-001", description: "Task 1" }), createMockTask({ id: "FN-001", description: "Task 1" }),
]; ];

View File

@@ -71,13 +71,15 @@ export function useTasks(options?: UseTasksOptions) {
tasksRef.current = tasks; tasksRef.current = tasks;
searchQueryRef.current = searchQuery; searchQueryRef.current = searchQuery;
// Detect project changes and invalidate SSE context // Detect project changes and invalidate SSE context.
// Keep previous tasks visible while the new project's fetch is in flight
// (stale-while-revalidate) to avoid a blank flash and a full empty→populated
// re-reconcile of the board. The refreshTasks fetch guard (requestProjectId)
// rejects late responses from the previous project, and SSE handlers check
// projectContextVersionRef before applying events.
if (previousProjectIdRef.current !== projectId) { if (previousProjectIdRef.current !== projectId) {
previousProjectIdRef.current = projectId; previousProjectIdRef.current = projectId;
projectContextVersionRef.current++; projectContextVersionRef.current++;
// Clear tasks immediately on project change so prior-project rows are not rendered
// during the fetch gap. This is scoped to project-context transitions only.
setTasks([]);
} }
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000; const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;