From 6c5aef10e9c02614cc10aaa976b4c402b71ccfb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 13:49:55 +0000 Subject: [PATCH] fix(ui): fit the view after React Flow renders the new layout Both fitView calls ran synchronously after the store update that replaces the nodes (setLayout / setFlow). React Flow had not rendered or measured the new nodes yet, so the fit was computed against the previous view. Both are now deferred to the next animation frame. Measured in Chromium (Playwright) on the bundled graph.json, 3 runs each: - clicking a node into flow view: main fits 2 of 95 nodes, zoomed to the 2x cap on the clicked node; with the fix all 95 are in view. - initial load: all 94 nodes in view either way, but the fix fits them tighter (scale 0.197 -> 0.257), the old fit having included stale bounds. No console errors in either. useFlowNavigation.test.ts pinned the synchronous call; it now asserts that fitView is not called in the same tick and is called, with the same options, once the frame runs. It fails against the old hook. Found by gemini-code-assist on #522, where it was out of scope for a formatting-only change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uv6LUrB5HCQcqBvmcixYn3 --- ui/src/hooks/useFlowNavigation.test.ts | 9 +++++++++ ui/src/hooks/useFlowNavigation.ts | 4 +++- ui/src/hooks/useLayoutComputation.ts | 3 ++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/ui/src/hooks/useFlowNavigation.test.ts b/ui/src/hooks/useFlowNavigation.test.ts index 1410c890..395979a1 100644 --- a/ui/src/hooks/useFlowNavigation.test.ts +++ b/ui/src/hooks/useFlowNavigation.test.ts @@ -129,13 +129,22 @@ describe("useFlowNavigation", () => { }); it("onNodeClick calls setFlow, setViewMode('flow'), and fitView", async () => { + const frames: FrameRequestCallback[] = []; + const raf = vi + .spyOn(globalThis, "requestAnimationFrame") + .mockImplementation((cb: FrameRequestCallback) => frames.push(cb)); const { result } = setup(); await act(async () => { await result.current.onNodeClick({} as any, { id: "a", type: "FUNCTION" } as any); }); expect(mockSetFlow).toHaveBeenCalled(); expect(mockSetViewMode).toHaveBeenCalledWith("flow"); + // Not in the same tick: React Flow has not rendered the new nodes yet, and a + // synchronous fitView measured the old view, zooming to the 2x cap. + expect(mockFitView).not.toHaveBeenCalled(); + frames.forEach((cb) => cb(0)); expect(mockFitView).toHaveBeenCalledWith({ padding: 0.15, duration: 250 }); + raf.mockRestore(); }); it("cache is keyed by graphVersion — same node, same version uses cache", async () => { diff --git a/ui/src/hooks/useFlowNavigation.ts b/ui/src/hooks/useFlowNavigation.ts index 83b3c497..15e70868 100644 --- a/ui/src/hooks/useFlowNavigation.ts +++ b/ui/src/hooks/useFlowNavigation.ts @@ -44,7 +44,9 @@ export function useFlowNavigation(allowedEdgeTypes: string[] = DEFAULT_ALLOWED) setFlow(layoutedNodes, flowEdges); setViewMode("flow"); - fitView({ padding: 0.15, duration: 250 }); + // After the frame that renders the new nodes: called synchronously, fitView + // measures the previous view and zooms to the 2x cap on the clicked node. + requestAnimationFrame(() => void fitView({ padding: 0.15, duration: 250 })); }, [rawNodes, rawEdges, graphVersion, allowedEdgeTypes, setFlow, setViewMode, fitView] ); diff --git a/ui/src/hooks/useLayoutComputation.ts b/ui/src/hooks/useLayoutComputation.ts index 5124be49..137d7936 100644 --- a/ui/src/hooks/useLayoutComputation.ts +++ b/ui/src/hooks/useLayoutComputation.ts @@ -88,7 +88,8 @@ export function useLayoutComputation(): void { if (generationRef.current !== gen) return; // stale async, discard setLayout(layoutedNodes, layoutedEdges); - fitView({ padding: 0.15, duration: 250 }); + // After the frame that renders the new layout, not against the old one. + requestAnimationFrame(() => void fitView({ padding: 0.15, duration: 250 })); })(); }, [ rawNodes,