From 72e652caff1b700ac29fed8a0633b01f5945e20f Mon Sep 17 00:00:00 2001 From: Lakshay Saini Date: Sat, 15 Aug 2026 17:19:28 +0530 Subject: [PATCH] fix(explorer): gate temporal requests on graph load --- explorer/package.json | 2 +- .../GraphWorkspace/GraphWorkspace.tsx | 32 ++++- .../temporalLifecyclePredicates.ts | 31 +++++ explorer/tests/temporalLifecycle.test.ts | 114 ++++++++++++++++++ 4 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 explorer/src/workspaces/GraphWorkspace/temporalLifecyclePredicates.ts create mode 100644 explorer/tests/temporalLifecycle.test.ts diff --git a/explorer/package.json b/explorer/package.json index 72e36f73e..162f2bc07 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -9,7 +9,7 @@ "lint": "eslint .", "preview": "vite preview", "test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs", - "test:graph-workspace": "node --import tsx --test tests/graphSceneState.display.test.ts", + "test:graph-workspace": "node --import tsx --test tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts", "test:plugin-registry": "node --import tsx --test tests/pluginRegistry.temporal.test.mjs" }, "dependencies": { diff --git a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx index d38d21ad7..e077980b4 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx @@ -40,6 +40,7 @@ import { type GraphPluginToolbarItem, } from "./plugins"; import { explorationEffectsShouldLoad, neighborhoodPanelShouldLoad, temporalOverlayShouldLoad } from "./pluginRegistryPredicates"; +import { shouldFetchTemporalBounds, shouldFetchTemporalSnapshot } from "./temporalLifecyclePredicates"; import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel"; import type { GraphSceneHandle, GraphSceneRuntime } from "./scene"; import type { @@ -1440,7 +1441,18 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap applyGraphReadySummary(summary); }, [applyGraphReadySummary, graphReady, summary]); + const canFetchTemporalBounds = shouldFetchTemporalBounds(summary); + const canFetchTemporalSnapshot = shouldFetchTemporalSnapshot({ + debouncedTime, + isLoading, + summary, + }); + useEffect(() => { + if (!canFetchTemporalBounds) { + return; + } + let cancelled = false; const loadBounds = async () => { try { @@ -1460,10 +1472,21 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap return () => { cancelled = true; }; - }, [summary?.nodeCount, summary?.edgeCount]); + }, [ + canFetchTemporalBounds, + summary?.nodeCount, + summary?.edgeCount, + ]); useEffect(() => { - if (!debouncedTime || isLoading) return; + if (!canFetchTemporalSnapshot) { + return; + } + + if (!debouncedTime) { + return; + } + let cancelled = false; const applySnapshot = async () => { @@ -1505,7 +1528,10 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap return () => { cancelled = true; }; - }, [debouncedTime, isLoading]); + }, [ + canFetchTemporalSnapshot, + debouncedTime, + ]); const resolveNodeIdForFocusedMode = useCallback(( nodeId: string, diff --git a/explorer/src/workspaces/GraphWorkspace/temporalLifecyclePredicates.ts b/explorer/src/workspaces/GraphWorkspace/temporalLifecyclePredicates.ts new file mode 100644 index 000000000..bea2575b4 --- /dev/null +++ b/explorer/src/workspaces/GraphWorkspace/temporalLifecyclePredicates.ts @@ -0,0 +1,31 @@ +import type { GraphLoadSummary } from "./types"; + +/** + * Predicates for gating GraphWorkspace temporal API requests. + * + * Temporal bounds and snapshot requests must strictly not execute until the + * initial graph load has succeeded (summary !== undefined). An empty graph + * (nodeCount: 0) is still a successful load and must not be rejected. + */ + +export function shouldFetchTemporalBounds( + summary: GraphLoadSummary | undefined, +): boolean { + return summary !== undefined; +} + +export function shouldFetchTemporalSnapshot({ + debouncedTime, + isLoading, + summary, +}: { + debouncedTime: Date | null; + isLoading: boolean; + summary: GraphLoadSummary | undefined; +}): boolean { + return ( + summary !== undefined && + debouncedTime !== null && + !isLoading + ); +} diff --git a/explorer/tests/temporalLifecycle.test.ts b/explorer/tests/temporalLifecycle.test.ts new file mode 100644 index 000000000..c9aa31919 --- /dev/null +++ b/explorer/tests/temporalLifecycle.test.ts @@ -0,0 +1,114 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + shouldFetchTemporalBounds, + shouldFetchTemporalSnapshot, +} from "../src/workspaces/GraphWorkspace/temporalLifecyclePredicates.ts"; +import type { GraphLoadSummary } from "../src/workspaces/GraphWorkspace/types.ts"; + +const sampleSummary: GraphLoadSummary = { + nodeCount: 42, + edgeCount: 78, + loadTimeMs: 120, + hasCoordinates: true, + layoutSource: "provided", + layoutReady: true, +}; + +const emptyGraphSummary: GraphLoadSummary = { + nodeCount: 0, + edgeCount: 0, + loadTimeMs: 15, + hasCoordinates: false, + layoutSource: "runtime", + layoutReady: false, +}; + +// ── shouldFetchTemporalBounds ──────────────────────────────────────────────── + +test("temporal bounds: false when summary is undefined (initial mount or failed load)", () => { + assert.equal( + shouldFetchTemporalBounds(undefined), + false, + "bounds request must not run before graph load succeeds", + ); +}); + +test("temporal bounds: true when non-empty summary is present", () => { + assert.equal( + shouldFetchTemporalBounds(sampleSummary), + true, + "bounds request should run when successful graph summary exists", + ); +}); + +test("temporal bounds: true when successful summary has nodeCount of 0", () => { + assert.equal( + shouldFetchTemporalBounds(emptyGraphSummary), + true, + "an empty graph is still a successful load and must allow bounds fetching", + ); +}); + +// ── shouldFetchTemporalSnapshot ────────────────────────────────────────────── + +test("temporal snapshot: false when summary is undefined even if scrubber time is set and isLoading is false", () => { + assert.equal( + shouldFetchTemporalSnapshot({ + debouncedTime: new Date("2024-01-01T00:00:00Z"), + isLoading: false, + summary: undefined, + }), + false, + "snapshot request must not run when graph load failed", + ); +}); + +test("temporal snapshot: false when graph is currently loading", () => { + assert.equal( + shouldFetchTemporalSnapshot({ + debouncedTime: new Date("2024-01-01T00:00:00Z"), + isLoading: true, + summary: sampleSummary, + }), + false, + "snapshot request must not run while graph is loading", + ); +}); + +test("temporal snapshot: false when debouncedTime is null", () => { + assert.equal( + shouldFetchTemporalSnapshot({ + debouncedTime: null, + isLoading: false, + summary: sampleSummary, + }), + false, + "snapshot request must not run without a scrubber timestamp", + ); +}); + +test("temporal snapshot: true when summary exists, isLoading is false, and time is set", () => { + assert.equal( + shouldFetchTemporalSnapshot({ + debouncedTime: new Date("2024-01-01T00:00:00Z"), + isLoading: false, + summary: sampleSummary, + }), + true, + "snapshot request should run after graph load succeeds and time is set", + ); +}); + +test("temporal snapshot: true when successful summary has 0 nodes, isLoading is false, and time is set", () => { + assert.equal( + shouldFetchTemporalSnapshot({ + debouncedTime: new Date("2024-01-01T00:00:00Z"), + isLoading: false, + summary: emptyGraphSummary, + }), + true, + "empty successful graph must allow snapshot requests once ready", + ); +});