FN-8703: render project overview before health telemetry
Render registered project cards immediately while optional health telemetry hydrates in the background. - Publish completed health batches progressively and prevent stale responses from overwriting current state. - Keep project controls usable while health metrics load and indicate per-card pending telemetry. - Add coverage and operator documentation for progressive overview hydration. Files changed: .changeset/fn-8703-project-overview-load.md | 7 ++ docs/dashboard-guide.md | 3 +- packages/dashboard/app/components/ProjectCard.tsx | 12 ++- .../dashboard/app/components/ProjectOverview.tsx | 15 +-- .../components/__tests__/ProjectOverview.test.tsx | 101 ++++++++++++++++++++ .../app/hooks/__tests__/useProjectHealth.test.ts | 98 ++++++++++++++++++++ packages/dashboard/app/hooks/useProjectHealth.ts | 102 ++++++++++----------- 7 files changed, 277 insertions(+), 61 deletions(-) Fusion-Task-Id: FN-8703 Fusion-Task-Lineage: eb816c9d-c808-4d52-a3f0-28bb9de5f149 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8703-project-overview-load.md
Normal file
7
.changeset/fn-8703-project-overview-load.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Show registered projects immediately while dashboard health metrics load.
|
||||
category: fix
|
||||
dev: Project health batches now hydrate cards and aggregate metrics progressively.
|
||||
@@ -182,7 +182,8 @@ The active nav-item highlight and the resize-handle hover/focus accent track the
|
||||
On mobile viewports (`<=768px`), the sidebar is not rendered even when the default-on setting is enabled. The existing bottom `MobileNavBar` remains the navigation surface on project task screens, with mobile-only More-sheet entries for compact tools such as Git Manager, Terminal, Files, and **Import from GitHub**.
|
||||
|
||||
<!-- FNXC:DashboardResponsiveDocs 2026-07-25-22:57: Project overview has no task destinations, so the mobile navigation and its published height must be absent rather than leaving dead bottom space. Document the 320px responsive and token conventions alongside the operator-visible behavior. -->
|
||||
On the **All Projects** overview, Fusion suppresses the mobile bottom nav because task tabs are not active there. It also clears `--mobile-nav-height`, so the overview does not reserve bottom-bar space. The overview reflows through 320px: headers, filters, stats, and card actions stack or wrap, while long project names and paths truncate rather than overflowing. Dashboard component styles use literal pixel values for media-query breakpoints (including `480px` and `380px`); declaration values within those blocks stay token-based.
|
||||
<!-- FNXC:ProjectOverviewHealthHydration 2026-08-01-15:40: Registered projects are the navigation-critical content of the all-projects view, so optional per-project health telemetry hydrates progressively rather than delaying cards and their controls. -->
|
||||
On the **All Projects** overview, Fusion renders registered project cards, navigation, filters, and controls as soon as the project list is available. Per-project health metrics hydrate progressively as bounded background requests complete; pending or failed telemetry never replaces the grid with a full loading skeleton. Fusion continues refreshing those metrics in the background and suspends polling while the tab is hidden. It suppresses the mobile bottom nav because task tabs are not active there and clears `--mobile-nav-height`, so the overview does not reserve bottom-bar space. The overview reflows through 320px: headers, filters, stats, and card actions stack or wrap, while long project names and paths truncate rather than overflowing. Dashboard component styles use literal pixel values for media-query breakpoints (including `480px` and `380px`); declaration values within those blocks stay token-based.
|
||||
|
||||
## Right Dock (experimental, default on)
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface ProjectCardProps {
|
||||
onResume: (project: RegisteredProject) => void;
|
||||
onRemove: (project: RegisteredProject) => void;
|
||||
availabilityMappings?: Array<ProjectNodeAvailability & { displayName: string }>;
|
||||
/** Health is being fetched, while project navigation and controls remain available. */
|
||||
healthLoading?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
@@ -62,7 +64,8 @@ function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardP
|
||||
if (previous.project.path !== next.project.path) return false;
|
||||
if (previous.project.lastActivityAt !== next.project.lastActivityAt) return false;
|
||||
if (previous.isLoading !== next.isLoading) return false;
|
||||
|
||||
if (previous.healthLoading !== next.healthLoading) return false;
|
||||
|
||||
// Compare health
|
||||
const prevHealth = previous.health;
|
||||
const nextHealth = next.health;
|
||||
@@ -101,6 +104,7 @@ function ProjectCardInner({
|
||||
onResume,
|
||||
onRemove,
|
||||
availabilityMappings = [],
|
||||
healthLoading = false,
|
||||
isLoading = false,
|
||||
}: ProjectCardProps) {
|
||||
const { t } = useTranslation("app");
|
||||
@@ -205,7 +209,11 @@ function ProjectCardInner({
|
||||
)}
|
||||
{!health && (
|
||||
<div className="project-card-metric project-card-metric-empty">
|
||||
<span className="project-card-metric-label">{t("projectCard.noHealthData", "No health data available")}</span>
|
||||
<span className="project-card-metric-label">
|
||||
{healthLoading
|
||||
? t("projectCard.healthLoading", "Loading health metrics")
|
||||
: t("projectCard.noHealthData", "No health data available")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -230,12 +230,13 @@ export function ProjectOverview({
|
||||
onSelectProject(project);
|
||||
}, [onSelectProject, recentProjectIds]);
|
||||
|
||||
// Determine if we need to show skeleton
|
||||
// Show skeleton for initial load if:
|
||||
// 1. Projects list is still loading, OR
|
||||
// 2. Projects exist but we haven't fetched health data yet (healthLoading with no data)
|
||||
// Don't show skeleton during background health polling when health data already exists
|
||||
const needsInitialSkeleton = loading || (healthLoading && projects.length > 0 && Object.keys(healthMap).length === 0);
|
||||
/*
|
||||
FNXC:ProjectOverviewHealthHydration 2026-08-01-15:40:
|
||||
Registered project metadata and controls are the navigation-critical surface, while health is optional,
|
||||
potentially slow per-project telemetry. Keep the grid visible as soon as the list resolves; cards mark only
|
||||
their own pending health rather than replacing the whole overview until every batch has completed.
|
||||
*/
|
||||
const needsInitialSkeleton = loading;
|
||||
/*
|
||||
FNXC:DashboardHeader 2026-06-22-16:42:
|
||||
The Project Dashboard overview (projects, stats, filters, and charts/overview content) owns the shared top header. The Board view must stay headerless because its columns already consume the full board surface.
|
||||
@@ -439,6 +440,7 @@ export function ProjectOverview({
|
||||
{/* Project grid */}
|
||||
<div className="project-grid">
|
||||
{sortedProjects.map(({ project, health }) => {
|
||||
const healthPending = healthLoading && !Object.hasOwn(healthMap, project.id);
|
||||
const availabilityMappings: DisplayMapping[] = getNodeMappingsForProject(project)
|
||||
.filter((mapping) => mapping.available)
|
||||
.map((mapping) => ({
|
||||
@@ -451,6 +453,7 @@ export function ProjectOverview({
|
||||
key={project.id}
|
||||
project={project}
|
||||
health={health}
|
||||
healthLoading={healthPending}
|
||||
availabilityMappings={availabilityMappings}
|
||||
onSelect={handleSelectProject}
|
||||
onPause={onPauseProject}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { ProjectOverview } from "../ProjectOverview";
|
||||
import * as api from "../../api";
|
||||
import type { ProjectHealth, ProjectInfoWithSource } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchProjectHealth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }),
|
||||
}));
|
||||
|
||||
const mockFetchProjectHealth = vi.mocked(api.fetchProjectHealth);
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function createProject(id: string): ProjectInfoWithSource {
|
||||
return {
|
||||
id,
|
||||
name: `Project ${id}`,
|
||||
path: `/workspace/${id}`,
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function createHealth(projectId: string): ProjectHealth {
|
||||
return {
|
||||
projectId,
|
||||
status: "active",
|
||||
activeTaskCount: 3,
|
||||
inFlightAgentCount: 1,
|
||||
totalTasksCompleted: 12,
|
||||
totalTasksFailed: 0,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ProjectOverview health hydration", () => {
|
||||
it("keeps registered project cards and controls usable while batched health resolves progressively", async () => {
|
||||
const pendingHealth = new Map(
|
||||
Array.from({ length: 6 }, (_, index) => {
|
||||
const id = `p${index + 1}`;
|
||||
return [id, deferred<ProjectHealth>()] as const;
|
||||
}),
|
||||
);
|
||||
const onSelectProject = vi.fn();
|
||||
mockFetchProjectHealth.mockImplementation((projectId: string) => pendingHealth.get(projectId)!.promise);
|
||||
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={Array.from({ length: 6 }, (_, index) => createProject(`p${index + 1}`))}
|
||||
onSelectProject={onSelectProject}
|
||||
onAddProject={vi.fn()}
|
||||
onPauseProject={vi.fn()}
|
||||
onResumeProject={vi.fn()}
|
||||
onRemoveProject={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchProjectHealth).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
expect(screen.getByText("Project p1")).toBeTruthy();
|
||||
expect(screen.getByText("Project p6")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Add Project" })).toBeTruthy();
|
||||
expect(screen.getByRole("combobox", { name: "Sort projects" })).toBeTruthy();
|
||||
expect(screen.getAllByRole("button", { name: "Open project" })).toHaveLength(6);
|
||||
|
||||
fireEvent.click(screen.getByText("Project p1"));
|
||||
expect(onSelectProject).toHaveBeenCalledWith(expect.objectContaining({ id: "p1" }));
|
||||
|
||||
await act(async () => {
|
||||
for (let index = 1; index <= 5; index += 1) {
|
||||
pendingHealth.get(`p${index}`)!.resolve(createHealth(`p${index}`));
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("12")).toHaveLength(5);
|
||||
expect(mockFetchProjectHealth).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
expect(screen.getByText("Project p6")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -145,6 +145,104 @@ describe("useProjectHealth", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes each completed batch before a later batch settles", async () => {
|
||||
const firstBatch = Array.from({ length: 5 }, () => deferred<ProjectHealth>());
|
||||
const sixthProject = deferred<ProjectHealth>();
|
||||
mockFetchProjectHealth.mockImplementation((id: string) => {
|
||||
const index = Number(id.slice(1)) - 1;
|
||||
return index < 5 ? firstBatch[index].promise : sixthProject.promise;
|
||||
});
|
||||
|
||||
const { result } = renderUseProjectHealth(["p1", "p2", "p3", "p4", "p5", "p6"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchProjectHealth).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
firstBatch.forEach((pending, index) => pending.resolve(createHealth(`p${index + 1}`)));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.healthMap).toEqual({
|
||||
p1: createHealth("p1"),
|
||||
p2: createHealth("p2"),
|
||||
p3: createHealth("p3"),
|
||||
p4: createHealth("p4"),
|
||||
p5: createHealth("p5"),
|
||||
});
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(mockFetchProjectHealth).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
sixthProject.resolve(createHealth("p6"));
|
||||
await waitFor(() => {
|
||||
expect(result.current.healthMap.p6).toEqual(createHealth("p6"));
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates project IDs before fetching health", async () => {
|
||||
const { result } = renderUseProjectHealth(["p1", "p1", "p2", "p2"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.healthMap).toEqual({
|
||||
p1: createHealth("p1"),
|
||||
p2: createHealth("p2"),
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockFetchProjectHealth.mock.calls.map(([id]) => id)).toEqual(["p1", "p2"]);
|
||||
});
|
||||
|
||||
it("does not publish an older project list after IDs change", async () => {
|
||||
const oldHealth = deferred<ProjectHealth>();
|
||||
const currentHealth = deferred<ProjectHealth>();
|
||||
mockFetchProjectHealth.mockImplementation((id: string) => id === "old" ? oldHealth.promise : currentHealth.promise);
|
||||
|
||||
const { result, rerender } = renderUseProjectHealth(["old"]);
|
||||
await waitFor(() => expect(mockFetchProjectHealth).toHaveBeenCalledWith("old"));
|
||||
|
||||
rerender({ ids: ["current"] });
|
||||
await waitFor(() => expect(mockFetchProjectHealth).toHaveBeenCalledWith("current"));
|
||||
|
||||
currentHealth.resolve(createHealth("current"));
|
||||
await waitFor(() => expect(result.current.healthMap).toEqual({ current: createHealth("current") }));
|
||||
|
||||
oldHealth.resolve(createHealth("old", { activeTaskCount: 99 }));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.healthMap).toEqual({ current: createHealth("current") });
|
||||
});
|
||||
|
||||
it("does not let an older manual refresh overwrite newer health", async () => {
|
||||
const olderHealth = deferred<ProjectHealth>();
|
||||
const newerHealth = deferred<ProjectHealth>();
|
||||
mockFetchProjectHealth
|
||||
.mockReturnValueOnce(olderHealth.promise)
|
||||
.mockReturnValueOnce(newerHealth.promise);
|
||||
|
||||
const { result } = renderUseProjectHealth(["p1"]);
|
||||
await waitFor(() => expect(mockFetchProjectHealth).toHaveBeenCalledTimes(1));
|
||||
|
||||
await act(async () => {
|
||||
void result.current.refresh();
|
||||
});
|
||||
await waitFor(() => expect(mockFetchProjectHealth).toHaveBeenCalledTimes(2));
|
||||
|
||||
newerHealth.resolve(createHealth("p1", { activeTaskCount: 2 }));
|
||||
await waitFor(() => expect(result.current.healthMap.p1?.activeTaskCount).toBe(2));
|
||||
|
||||
olderHealth.resolve(createHealth("p1", { activeTaskCount: 99 }));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.healthMap.p1?.activeTaskCount).toBe(2);
|
||||
});
|
||||
|
||||
it("refresh aborts in-flight requests when called again", async () => {
|
||||
const abortSpy = vi.spyOn(AbortController.prototype, "abort");
|
||||
const pending = deferred<ProjectHealth>();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import type { ProjectHealth } from "../api";
|
||||
import { fetchProjectHealth } from "../api";
|
||||
import { isVisibilityResumeError, useTabVisibilitySuspension, useVisibilityAwarePoll } from "./visibilitySuspension";
|
||||
@@ -32,13 +32,16 @@ const BATCH_SIZE = 5; // Number of concurrent health fetches
|
||||
* skeleton flicker and scroll position resets during periodic updates.
|
||||
*/
|
||||
export function useProjectHealth(projectIds: string[]): UseMultiProjectHealthResult {
|
||||
// The caller can provide duplicate IDs from overlapping node sources; fetch and publish each logical project once.
|
||||
const projectIdsKey = projectIds.join("\u0000");
|
||||
const uniqueProjectIds = useMemo(() => [...new Set(projectIds)], [projectIdsKey]);
|
||||
const [healthMap, setHealthMap] = useState<Record<string, ProjectHealth | null>>({});
|
||||
const [loading, setLoading] = useState(true); // Start true for initial load
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const requestVersionRef = useRef(0);
|
||||
const healthMapRef = useRef(healthMap);
|
||||
const visibilitySuspension = useTabVisibilitySuspension();
|
||||
// Track if we've completed the initial load
|
||||
const initialLoadCompleteRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -49,44 +52,31 @@ export function useProjectHealth(projectIds: string[]): UseMultiProjectHealthRes
|
||||
return Object.keys(healthMapRef.current).length > 0 && isVisibilityResumeError(errorMessage, visibilitySuspension.wasRecentlyHidden());
|
||||
}, [visibilitySuspension]);
|
||||
|
||||
/**
|
||||
* Refresh health data for all projects.
|
||||
* This is called both for initial load and for background polling.
|
||||
* Background polling does NOT set loading=true to avoid UI flicker.
|
||||
*/
|
||||
const refresh = useCallback(async () => {
|
||||
// Handle empty projectIds - clear health state and complete initial load
|
||||
if (projectIds.length === 0) {
|
||||
if (uniqueProjectIds.length === 0) {
|
||||
abortRef.current?.abort();
|
||||
requestVersionRef.current += 1;
|
||||
setHealthMap({});
|
||||
// Mark initial load complete (there's nothing to fetch)
|
||||
if (!initialLoadCompleteRef.current) {
|
||||
initialLoadCompleteRef.current = true;
|
||||
}
|
||||
initialLoadCompleteRef.current = true;
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any in-flight requests
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
abortRef.current = new AbortController();
|
||||
|
||||
// Determine if this is the initial load
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const requestVersion = ++requestVersionRef.current;
|
||||
const isInitial = !initialLoadCompleteRef.current;
|
||||
|
||||
if (isInitial) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Fetch health in batches
|
||||
const newHealthMap: Record<string, ProjectHealth | null> = {};
|
||||
|
||||
for (let i = 0; i < projectIds.length; i += BATCH_SIZE) {
|
||||
const batch = projectIds.slice(i, i + BATCH_SIZE);
|
||||
|
||||
// Fetch this batch concurrently
|
||||
for (let index = 0; index < uniqueProjectIds.length; index += BATCH_SIZE) {
|
||||
const batch = uniqueProjectIds.slice(index, index + BATCH_SIZE);
|
||||
const batchResults = await Promise.allSettled(
|
||||
batch.map(async (id) => {
|
||||
try {
|
||||
@@ -94,43 +84,55 @@ export function useProjectHealth(projectIds: string[]): UseMultiProjectHealthRes
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
batch.forEach((id, index) => {
|
||||
const result = batchResults[index];
|
||||
newHealthMap[id] = result.status === "fulfilled" ? result.value : null;
|
||||
});
|
||||
|
||||
// Check for cancellation between batches
|
||||
if (abortRef.current?.signal.aborted) {
|
||||
if (controller.signal.aborted || requestVersion !== requestVersionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const completedBatch = Object.fromEntries(batch.map((id, batchIndex) => {
|
||||
const result = batchResults[batchIndex];
|
||||
return [id, result.status === "fulfilled" ? result.value : null];
|
||||
}));
|
||||
|
||||
/*
|
||||
FNXC:ProjectHealthProgress 2026-08-01-15:40:
|
||||
Health metrics are independent, optional telemetry. Publish each completed bounded batch immediately
|
||||
so ProjectOverview hydrates visible cards and aggregate values progressively; a slower later batch must
|
||||
not hold completed project data behind the initial-load gate.
|
||||
*/
|
||||
setHealthMap((previous) => ({ ...previous, ...completedBatch }));
|
||||
}
|
||||
|
||||
setHealthMap(newHealthMap);
|
||||
initialLoadCompleteRef.current = true;
|
||||
if (requestVersion === requestVersionRef.current && !controller.signal.aborted) {
|
||||
initialLoadCompleteRef.current = true;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
// Ignore abort errors
|
||||
if (controller.signal.aborted || requestVersion !== requestVersionRef.current || (err instanceof Error && err.name === "AbortError")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to fetch health data";
|
||||
if (!shouldSuppressVisibilityResumeError(errorMessage)) {
|
||||
setError(errorMessage);
|
||||
}
|
||||
// Mark initial load complete even on error so we don't stay in loading state
|
||||
initialLoadCompleteRef.current = true;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (requestVersion === requestVersionRef.current && !controller.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [projectIds, shouldSuppressVisibilityResumeError]);
|
||||
}, [shouldSuppressVisibilityResumeError, uniqueProjectIds]);
|
||||
|
||||
const refreshProject = useCallback(async (projectId: string) => {
|
||||
const requestVersion = requestVersionRef.current;
|
||||
try {
|
||||
const health = await fetchProjectHealth(projectId);
|
||||
setHealthMap((prev) => ({
|
||||
...prev,
|
||||
if (requestVersion !== requestVersionRef.current) return;
|
||||
|
||||
setHealthMap((previous) => ({
|
||||
...previous,
|
||||
[projectId]: health,
|
||||
}));
|
||||
} catch (err) {
|
||||
@@ -138,17 +140,13 @@ export function useProjectHealth(projectIds: string[]): UseMultiProjectHealthRes
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial fetch and when project IDs change
|
||||
useEffect(() => {
|
||||
// Reset initial load state when projectIds changes
|
||||
initialLoadCompleteRef.current = false;
|
||||
|
||||
setHealthMap({});
|
||||
void refresh();
|
||||
|
||||
return () => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
@@ -158,7 +156,7 @@ export function useProjectHealth(projectIds: string[]): UseMultiProjectHealthRes
|
||||
page from ever going idle, which is what makes mobile browsers reclaim the tab and force a cold reload on
|
||||
return; one refresh fires on the hidden -> visible edge so health badges are not stale when seen.
|
||||
*/
|
||||
useVisibilityAwarePoll(refresh, POLL_INTERVAL_MS, { enabled: projectIds.length > 0 });
|
||||
useVisibilityAwarePoll(refresh, POLL_INTERVAL_MS, { enabled: uniqueProjectIds.length > 0 });
|
||||
|
||||
return {
|
||||
healthMap,
|
||||
|
||||
Reference in New Issue
Block a user