fix(workbench): stabilize investigation presentation rendering
Keep investigation graphs stable during updates, defer expensive chart rendering, and prevent stale results when switching tasks. Adopt reference annotations and consolidate frontend documentation.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import type { RefObject } from "react";
|
||||
import { useLayoutEffect, type RefObject } from "react";
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import type { WorkbenchMapController } from "../map/workbench-map-controller";
|
||||
import type { WorkbenchSyncState } from "./use-workbench-sync";
|
||||
@@ -15,6 +15,21 @@ const options = { scope: "preview" as const, enabled: false, task: null, project
|
||||
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
|
||||
it("never commits a previous task's display under the newly selected task scope", async () => {
|
||||
const commits: Array<{ scope: string; records: unknown[] }> = [];
|
||||
const { result, rerender } = renderHook(({ scopeId }) => {
|
||||
const presentation = useInvestigationPresentation({ ...options, scopeId });
|
||||
useLayoutEffect(() => {
|
||||
commits.push({ scope: scopeId, records: presentation.records });
|
||||
});
|
||||
return presentation;
|
||||
}, { initialProps: { scopeId: "task-a" } });
|
||||
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "old task" }], commands: [] }));
|
||||
expect(result.current.records).toEqual([{ value: "old task" }]);
|
||||
rerender({ scopeId: "task-b" });
|
||||
expect(commits.filter((entry) => entry.scope === "task-b").every((entry) => entry.records.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("restores the last successful result when an unfinished display is aborted", async () => {
|
||||
const { result } = renderHook(() => useInvestigationPresentation(options));
|
||||
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "saved" }], commands: [] }));
|
||||
|
||||
@@ -120,16 +120,25 @@ export function useInvestigationPresentation(options: Options) {
|
||||
const valuesRef = useRef(options);
|
||||
valuesRef.current = options;
|
||||
const storeRef = useRef(new PresentationViewHistory());
|
||||
const displayScope = useRef(windowScope);
|
||||
if (displayScope.current !== windowScope) {
|
||||
displayScope.current = windowScope;
|
||||
// The first render of a new task must not re-key the previous task's charts.
|
||||
// Keep saved history; effects below still own cancellation and map cleanup.
|
||||
storeRef.current.select(null);
|
||||
storeRef.current.update(emptyPresentationView());
|
||||
}
|
||||
const [, refresh] = useState(0);
|
||||
const publish = useCallback(() => refresh((value) => value + 1), []);
|
||||
const [localPreview, setLocalPreview] = useState(false);
|
||||
const localPreviewRef = useRef(false);
|
||||
const [cameraPaused, setCameraPaused] = useState(false);
|
||||
const cameraPausedRef = useRef(false);
|
||||
const [status, setStatus] = useState<
|
||||
"connecting" | "live" | "history" | "disconnected" | "error"
|
||||
>("disconnected");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connection, setConnection] = useState<{
|
||||
scope: string;
|
||||
status: "connecting" | "live" | "history" | "disconnected" | "error";
|
||||
error: string | null;
|
||||
}>({ scope: windowScope, status: "disconnected", error: null });
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
||||
const [activity, setActivity] = useState<InvestigationPresentationActivity[]>([]);
|
||||
const activityIdRef = useRef(0);
|
||||
@@ -409,8 +418,9 @@ export function useInvestigationPresentation(options: Options) {
|
||||
clear,
|
||||
stopPulse: () => scene.stopPulse(),
|
||||
onStatus: (next, message) => {
|
||||
setStatus(!valuesRef.current.enabled && next === "live" ? "history" : next);
|
||||
setError(message ?? null);
|
||||
setConnection({ scope: windowScope,
|
||||
status: !valuesRef.current.enabled && next === "live" ? "history" : next,
|
||||
error: message ?? null });
|
||||
},
|
||||
execute: async (command, signal, event) => {
|
||||
await executeCommand(command, signal, "live", event);
|
||||
@@ -434,7 +444,8 @@ export function useInvestigationPresentation(options: Options) {
|
||||
publish,
|
||||
scene,
|
||||
subscribe,
|
||||
taskId
|
||||
taskId,
|
||||
windowScope
|
||||
]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -576,8 +587,8 @@ export function useInvestigationPresentation(options: Options) {
|
||||
evidence: visible.evidence,
|
||||
highlightLayers: visible.highlightLayers,
|
||||
cameraPaused: reviewing ? false : cameraPaused,
|
||||
status,
|
||||
error,
|
||||
status: connection.scope === windowScope ? connection.status : taskId ? "connecting" as const : "disconnected" as const,
|
||||
error: connection.scope === windowScope ? connection.error : null,
|
||||
activity,
|
||||
localPreview,
|
||||
hasHistory: taskEntries.length > 0,
|
||||
|
||||
Reference in New Issue
Block a user