FN-7256: reuse fresh task snapshots on board return
Reuse fresh task data when users return to Board or List views. - Track the confirmed task snapshot context and freshness window before deciding whether task-view re-entry needs a catch-up fetch. - Preserve SSE reconnect, stale snapshot, query, archive, project-change, missing-data, and error recovery fetch paths. - Cover fresh, stale, empty, failed, no-snapshot, project-scoped, and reconnect return behavior in useTasks tests. - Document the Board/List return cache behavior and add a patch changeset for the published CLI package. Files changed: .changeset/fn-7256-board-return-cache.md | 7 + docs/dashboard-guide.md | 2 + .../dashboard/app/hooks/__tests__/useTasks.test.ts | 221 ++++++++++++++++++++- packages/dashboard/app/hooks/useTasks.ts | 34 +++- 4 files changed, 258 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-7256 Fusion-Task-Lineage: 14dd91c4-bf06-4dcf-8324-2572828fd2ee Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7256-board-return-cache.md
Normal file
7
.changeset/fn-7256-board-return-cache.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Reuse fresh task data when returning to Board or List views.
|
||||||
|
category: fix
|
||||||
|
dev: Skips the useTasks false-to-true SSE catch-up fetch while the in-memory snapshot is within SWR_TASKS_MAX_AGE_MS.
|
||||||
@@ -146,6 +146,8 @@ Features:
|
|||||||
<!-- FNXC:WorkflowSelection 2026-06-29-23:59: Workflow counts and All workflows grouping resolve each task's effective workflow before evaluating column visibility, so a shared column id hidden in one workflow does not leak that workflow's hidden tasks into another workflow's visible aggregate lane. -->
|
<!-- FNXC:WorkflowSelection 2026-06-29-23:59: Workflow counts and All workflows grouping resolve each task's effective workflow before evaluating column visibility, so a shared column id hidden in one workflow does not leak that workflow's hidden tasks into another workflow's visible aggregate lane. -->
|
||||||
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow name and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived and board-hidden columns) refresh each time the dropdown opens and appear while the dropdown is expanded, including on each workflow option. Built-in lanes with synthesized trait-less lifecycle columns fall back to canonical column ids (`todo`, `in-progress`, `done`, and `archived`) for those counts. Board also shows **All workflows** before real workflows as a dashboard-only aggregate view with combined counts and a deterministic union of visible workflow columns; shared column ids use the default workflow label/flags when available, otherwise the first workflow definition that declares the column. Hidden columns stay workflow-scoped in the aggregate: a task whose effective workflow hides a shared column is omitted from that aggregate column even if another workflow exposes the same column id. That option is not editable and is not saved as the durable selected workflow, and its quick-create affordance appears only on the chosen real workflow intake/default column so task creation still sends a real workflow id and column. Each real workflow option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. The open listbox grows from the longest workflow name plus its count/edit decorations while remaining viewport-bounded; the closed trigger stays narrow and ellipsized. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`.
|
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow name and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived and board-hidden columns) refresh each time the dropdown opens and appear while the dropdown is expanded, including on each workflow option. Built-in lanes with synthesized trait-less lifecycle columns fall back to canonical column ids (`todo`, `in-progress`, `done`, and `archived`) for those counts. Board also shows **All workflows** before real workflows as a dashboard-only aggregate view with combined counts and a deterministic union of visible workflow columns; shared column ids use the default workflow label/flags when available, otherwise the first workflow definition that declares the column. Hidden columns stay workflow-scoped in the aggregate: a task whose effective workflow hides a shared column is omitted from that aggregate column even if another workflow exposes the same column id. That option is not editable and is not saved as the durable selected workflow, and its quick-create affordance appears only on the chosen real workflow intake/default column so task creation still sends a real workflow id and column. Each real workflow option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. The open listbox grows from the longest workflow name plus its count/edit decorations while remaining viewport-bounded; the closed trigger stays narrow and ellipsized. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`.
|
||||||
- When workflow columns are enabled, Board and List hydrate the last successful workflow-lane payload from a per-project session cache; cold loads show a neutral skeleton until settings and workflow metadata are known, avoiding a legacy single-lane flash. The selected workflow is remembered per project in durable browser storage and restored when returning to Board/List after task refreshes, route changes, or respecification flows; if that saved workflow is later deleted, Fusion falls back to a valid default/first workflow so tasks remain visible.
|
- When workflow columns are enabled, Board and List hydrate the last successful workflow-lane payload from a per-project session cache; cold loads show a neutral skeleton until settings and workflow metadata are known, avoiding a legacy single-lane flash. The selected workflow is remembered per project in durable browser storage and restored when returning to Board/List after task refreshes, route changes, or respecification flows; if that saved workflow is later deleted, Fusion falls back to a valid default/first workflow so tasks remain visible.
|
||||||
|
- Briefly leaving Board/List for a task detail or another non-task-SSE view preserves the current in-memory task snapshot. Returning to Board/List reuses that fresh snapshot immediately and restores live SSE updates without an extra all-task fetch; Fusion still runs one catch-up fetch when task data is missing, stale, or from a failed refresh.
|
||||||
|
<!-- FNXC:BoardTaskCache 2026-06-29-20:05: Board/List returns from non-task-SSE views should reuse a fresh in-memory task snapshot to avoid redundant all-task fetches and loading flashes, while stale, missing, or errored snapshots still trigger one catch-up fetch and restore SSE updates. -->
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -366,7 +366,82 @@ describe("useTasks", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("view-transition refresh behavior", () => {
|
describe("view-transition refresh behavior", () => {
|
||||||
it("refetches exactly once when sseEnabled flips from false to true and updates state", async () => {
|
it("skips the false-to-true catch-up when the in-memory snapshot is fresh", async () => {
|
||||||
|
const initialTask = createMockTask({ id: "FN-001", title: "Before return" });
|
||||||
|
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ sseEnabled }: { sseEnabled: boolean }) => useTasks({ sseEnabled }),
|
||||||
|
{ initialProps: { sseEnabled: false } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.tasks[0]?.id).toBe("FN-001");
|
||||||
|
});
|
||||||
|
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||||
|
mockFetchTasks.mockClear();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rerender({ sseEnabled: true });
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetchTasks).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.tasks[0]?.id).toBe("FN-001");
|
||||||
|
expect(MockEventSource.instances).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips only same-project fresh returns and restores the project-scoped SSE subscription", async () => {
|
||||||
|
const projectTask = createMockTask({ id: "FN-PROJ-1", title: "Project one" });
|
||||||
|
mockFetchTasks.mockResolvedValueOnce([projectTask]);
|
||||||
|
mockReadCache.mockImplementation((key) => {
|
||||||
|
if (key === `${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-2`) {
|
||||||
|
return [createMockTask({ id: "FN-PROJ-2", title: "Project two cache" })];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ projectId, sseEnabled }: { projectId: string; sseEnabled: boolean }) =>
|
||||||
|
useTasks({ projectId, sseEnabled }),
|
||||||
|
{ initialProps: { projectId: "proj-1", sseEnabled: false } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.tasks[0]?.id).toBe("FN-PROJ-1");
|
||||||
|
});
|
||||||
|
expect(mockFetchTasks).toHaveBeenLastCalledWith(undefined, undefined, "proj-1", undefined, false);
|
||||||
|
mockFetchTasks.mockClear();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rerender({ projectId: "proj-1", sseEnabled: true });
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetchTasks).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.tasks[0]?.id).toBe("FN-PROJ-1");
|
||||||
|
expect(MockEventSource.instances.at(-1)?.url).toContain("/api/events?projectId=proj-1");
|
||||||
|
|
||||||
|
mockFetchTasks.mockResolvedValueOnce([createMockTask({ id: "FN-PROJ-2-LIVE" })]);
|
||||||
|
await act(async () => {
|
||||||
|
rerender({ projectId: "proj-2", sseEnabled: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockReadCache).toHaveBeenCalledWith(
|
||||||
|
`${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-2`,
|
||||||
|
{ maxAgeMs: swrCache.SWR_TASKS_MAX_AGE_MS },
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchTasks).toHaveBeenCalledWith(undefined, undefined, "proj-2", undefined, false);
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.tasks[0]?.id).toBe("FN-PROJ-2-LIVE");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("performs one false-to-true catch-up when the confirmed snapshot is stale", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
vi.setSystemTime(new Date("2026-06-29T22:00:00.000Z"));
|
||||||
const initialTask = createMockTask({ id: "FN-001", title: "Before return" });
|
const initialTask = createMockTask({ id: "FN-001", title: "Before return" });
|
||||||
const refreshedTask = createMockTask({ id: "FN-002", title: "After return" });
|
const refreshedTask = createMockTask({ id: "FN-002", title: "After return" });
|
||||||
mockFetchTasks
|
mockFetchTasks
|
||||||
@@ -383,6 +458,7 @@ describe("useTasks", () => {
|
|||||||
});
|
});
|
||||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.setSystemTime(Date.now() + swrCache.SWR_TASKS_MAX_AGE_MS + 1);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
rerender({ sseEnabled: true });
|
rerender({ sseEnabled: true });
|
||||||
});
|
});
|
||||||
@@ -393,6 +469,101 @@ describe("useTasks", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.tasks[0]?.id).toBe("FN-002");
|
expect(result.current.tasks[0]?.id).toBe("FN-002");
|
||||||
});
|
});
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a fresh empty server snapshot as confirmed data on task-view return", async () => {
|
||||||
|
mockFetchTasks.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ sseEnabled }: { sseEnabled: boolean }) => useTasks({ sseEnabled }),
|
||||||
|
{ initialProps: { sseEnabled: false } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.lastFetchTimeMs).toEqual(expect.any(Number));
|
||||||
|
});
|
||||||
|
expect(result.current.tasks).toEqual([]);
|
||||||
|
mockFetchTasks.mockClear();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rerender({ sseEnabled: true });
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetchTasks).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.tasks).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("performs one false-to-true catch-up when no server snapshot has completed yet", async () => {
|
||||||
|
let resolveInitial: ((tasks: Task[]) => void) | undefined;
|
||||||
|
const recoveredTask = createMockTask({ id: "FN-NO-SNAPSHOT" });
|
||||||
|
mockFetchTasks
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<Task[]>((resolve) => {
|
||||||
|
resolveInitial = resolve;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce([recoveredTask]);
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ sseEnabled }: { sseEnabled: boolean }) => useTasks({ sseEnabled }),
|
||||||
|
{ initialProps: { sseEnabled: false } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(result.current.lastFetchTimeMs).toBeUndefined();
|
||||||
|
expect(result.current.tasks).toEqual([]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rerender({ sseEnabled: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchTasks).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.tasks[0]?.id).toBe("FN-NO-SNAPSHOT");
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveInitial?.([createMockTask({ id: "FN-STALE-INITIAL" })]);
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.tasks[0]?.id).toBe("FN-NO-SNAPSHOT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("performs one false-to-true catch-up after the last refresh errored", async () => {
|
||||||
|
const recoveredTask = createMockTask({ id: "FN-RECOVERED" });
|
||||||
|
mockFetchTasks
|
||||||
|
.mockRejectedValueOnce(new Error("offline"))
|
||||||
|
.mockResolvedValueOnce([recoveredTask]);
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ sseEnabled }: { sseEnabled: boolean }) => useTasks({ sseEnabled }),
|
||||||
|
{ initialProps: { sseEnabled: false } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.lastRefreshErrorAt).toEqual(expect.any(Number));
|
||||||
|
});
|
||||||
|
expect(result.current.tasks).toEqual([]);
|
||||||
|
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rerender({ sseEnabled: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchTasks).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.tasks[0]?.id).toBe("FN-RECOVERED");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not duplicate the initial fetch when mounting with sseEnabled true", async () => {
|
it("does not duplicate the initial fetch when mounting with sseEnabled true", async () => {
|
||||||
@@ -480,7 +651,50 @@ describe("useTasks", () => {
|
|||||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs one catch-up per false-to-true toggle without stacked EventSource instances", async () => {
|
it("keeps SSE reconnect resync active after a fresh return skips catch-up", async () => {
|
||||||
|
const initialTask = createMockTask({ id: "FN-INITIAL" });
|
||||||
|
const reconnectedTask = createMockTask({ id: "FN-RECONNECTED" });
|
||||||
|
mockFetchTasks
|
||||||
|
.mockResolvedValueOnce([initialTask])
|
||||||
|
.mockResolvedValueOnce([reconnectedTask]);
|
||||||
|
|
||||||
|
const { result, rerender, unmount } = renderHook(
|
||||||
|
({ sseEnabled }: { sseEnabled: boolean }) => useTasks({ sseEnabled }),
|
||||||
|
{ initialProps: { sseEnabled: false } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.tasks[0]?.id).toBe("FN-INITIAL");
|
||||||
|
});
|
||||||
|
mockFetchTasks.mockClear();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rerender({ sseEnabled: true });
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetchTasks).not.toHaveBeenCalled();
|
||||||
|
expect(MockEventSource.instances).toHaveLength(1);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
MockEventSource.instances[0]._emit("open");
|
||||||
|
MockEventSource.instances[0]._emit("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(mockFetchTasks).toHaveBeenLastCalledWith(undefined, undefined, undefined, undefined, false);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.tasks[0]?.id).toBe("FN-RECONNECTED");
|
||||||
|
});
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs one catch-up per stale false-to-true toggle without stacked EventSource instances", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
vi.setSystemTime(new Date("2026-06-29T22:10:00.000Z"));
|
||||||
const initialTask = createMockTask({ id: "FN-RAPID-0" });
|
const initialTask = createMockTask({ id: "FN-RAPID-0" });
|
||||||
const firstReturnTask = createMockTask({ id: "FN-RAPID-1" });
|
const firstReturnTask = createMockTask({ id: "FN-RAPID-1" });
|
||||||
const secondReturnTask = createMockTask({ id: "FN-RAPID-2" });
|
const secondReturnTask = createMockTask({ id: "FN-RAPID-2" });
|
||||||
@@ -498,6 +712,7 @@ describe("useTasks", () => {
|
|||||||
expect(result.current.tasks[0]?.id).toBe("FN-RAPID-0");
|
expect(result.current.tasks[0]?.id).toBe("FN-RAPID-0");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.setSystemTime(Date.now() + swrCache.SWR_TASKS_MAX_AGE_MS + 1);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
rerender({ sseEnabled: true });
|
rerender({ sseEnabled: true });
|
||||||
});
|
});
|
||||||
@@ -514,6 +729,7 @@ describe("useTasks", () => {
|
|||||||
expect(MockEventSource.instances).toHaveLength(1);
|
expect(MockEventSource.instances).toHaveLength(1);
|
||||||
expect(MockEventSource.instances[0]?.readyState).toBe(MockEventSource.CLOSED);
|
expect(MockEventSource.instances[0]?.readyState).toBe(MockEventSource.CLOSED);
|
||||||
|
|
||||||
|
vi.setSystemTime(Date.now() + swrCache.SWR_TASKS_MAX_AGE_MS + 1);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
rerender({ sseEnabled: true });
|
rerender({ sseEnabled: true });
|
||||||
});
|
});
|
||||||
@@ -524,6 +740,7 @@ describe("useTasks", () => {
|
|||||||
expect(mockFetchTasks).toHaveBeenCalledTimes(3);
|
expect(mockFetchTasks).toHaveBeenCalledTimes(3);
|
||||||
expect(MockEventSource.instances).toHaveLength(2);
|
expect(MockEventSource.instances).toHaveLength(2);
|
||||||
expect(MockEventSource.instances[1]?.readyState).toBe(1);
|
expect(MockEventSource.instances[1]?.readyState).toBe(1);
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { pushTrace } from "../utils/dashboardTraceBuffer";
|
|||||||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||||
|
|
||||||
const loggedTaskCacheHitProjects = new Set<string>();
|
const loggedTaskCacheHitProjects = new Set<string>();
|
||||||
|
const TASK_VIEW_REENTRY_FRESHNESS_MS = SWR_TASKS_MAX_AGE_MS;
|
||||||
|
|
||||||
function normalizeTask(task: Task): Task {
|
function normalizeTask(task: Task): Task {
|
||||||
return {
|
return {
|
||||||
@@ -137,6 +138,9 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
// Tracks when task data was last confirmed fresh by the server.
|
// Tracks when task data was last confirmed fresh by the server.
|
||||||
// Used to prevent false positives in stuck detection when tab has been in background.
|
// Used to prevent false positives in stuck detection when tab has been in background.
|
||||||
const lastFetchTimeMs = useRef<number | undefined>(undefined);
|
const lastFetchTimeMs = useRef<number | undefined>(undefined);
|
||||||
|
const lastConfirmedProjectIdRef = useRef<string | undefined>(undefined);
|
||||||
|
const lastConfirmedSearchQueryRef = useRef<string | undefined>(undefined);
|
||||||
|
const lastConfirmedIncludeArchivedRef = useRef(false);
|
||||||
// Track previous projectId to detect changes
|
// Track previous projectId to detect changes
|
||||||
const previousProjectIdRef = useRef<string | undefined>(projectId);
|
const previousProjectIdRef = useRef<string | undefined>(projectId);
|
||||||
tasksRef.current = tasks;
|
tasksRef.current = tasks;
|
||||||
@@ -177,6 +181,9 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
setLastRefreshErrorAt(null);
|
setLastRefreshErrorAt(null);
|
||||||
// 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();
|
||||||
|
lastConfirmedProjectIdRef.current = requestProjectId;
|
||||||
|
lastConfirmedSearchQueryRef.current = query;
|
||||||
|
lastConfirmedIncludeArchivedRef.current = wantArchived;
|
||||||
} catch {
|
} catch {
|
||||||
// Reject if project changed or version is stale
|
// Reject if project changed or version is stale
|
||||||
if (fetchVersionRef.current !== requestVersion || projectId !== requestProjectId) {
|
if (fetchVersionRef.current !== requestVersion || projectId !== requestProjectId) {
|
||||||
@@ -194,16 +201,35 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
refreshTasksRef.current = refreshTasks;
|
refreshTasksRef.current = refreshTasks;
|
||||||
|
|
||||||
// FNXC:DashboardLiveUpdates 2026-06-26-01:08:
|
const shouldRefreshOnTaskViewReentry = useCallback(() => {
|
||||||
// Task SSE is disabled outside Board/List, so task:created/moved/updated/deleted/merged events emitted off-view are never delivered. On the sseEnabled false→true re-entry, perform exactly one hook-owned catch-up refetch while excluding initial mount and the disabled state; visibilitychange and onReconnect keep their separate refetch ownership.
|
if (lastRefreshErrorAt !== null) return true;
|
||||||
|
if (searchQueryRef.current) return true;
|
||||||
|
if (includeArchivedRef.current) return true;
|
||||||
|
if (lastConfirmedProjectIdRef.current !== projectId) return true;
|
||||||
|
if (lastConfirmedSearchQueryRef.current !== searchQueryRef.current) return true;
|
||||||
|
if (lastConfirmedIncludeArchivedRef.current !== includeArchivedRef.current) return true;
|
||||||
|
|
||||||
|
const lastFetchAt = lastFetchTimeMs.current;
|
||||||
|
if (lastFetchAt === undefined) return true;
|
||||||
|
|
||||||
|
return Date.now() - lastFetchAt > TASK_VIEW_REENTRY_FRESHNESS_MS;
|
||||||
|
}, [lastRefreshErrorAt, projectId]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:DashboardTaskCache 2026-06-29-22:35:
|
||||||
|
Brief Board/List returns should reuse fresh in-memory task state instead of issuing another all-task fetch, so the existing task array renders immediately without an empty/loading shell. Stale, missing, failed, project/search, or archived snapshots still perform one catch-up because task SSE is disabled off task-list views and missed events need server confirmation.
|
||||||
|
|
||||||
|
FNXC:DashboardTaskCache 2026-06-29-23:12:
|
||||||
|
The freshness shortcut is scoped only to in-app task-view re-entry. Initial mount, tab visibility recovery, SSE reconnect resync, search refreshes, and delete fetch-version invalidation remain independent safety paths because each represents either a new browser/server gap or a changed query context.
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previous = prevSseEnabledRef.current;
|
const previous = prevSseEnabledRef.current;
|
||||||
prevSseEnabledRef.current = sseEnabled;
|
prevSseEnabledRef.current = sseEnabled;
|
||||||
|
|
||||||
if (previous === false && sseEnabled === true) {
|
if (previous === false && sseEnabled === true && shouldRefreshOnTaskViewReentry()) {
|
||||||
void refreshTasksRef.current();
|
void refreshTasksRef.current();
|
||||||
}
|
}
|
||||||
}, [sseEnabled]);
|
}, [shouldRefreshOnTaskViewReentry, sseEnabled]);
|
||||||
|
|
||||||
/** Lazy-load archived tasks. Called by the Board when the archived column is first expanded. */
|
/** Lazy-load archived tasks. Called by the Board when the archived column is first expanded. */
|
||||||
const loadArchivedTasks = useCallback(async () => {
|
const loadArchivedTasks = useCallback(async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user