diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index af8048c5..c875e073 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -3964,14 +3964,17 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const layoutReflowingRef = useRef(false); const mousedownNodeRef = useRef(null); - // Fit view to show all nodes - const fitViewToNodes = useCallback(() => { - if (!svgRef.current || !containerRef.current) return; + // Fit view to show all nodes. Returns true if a fit transform was applied, + // false if it couldn't yet (no svg/container, or the simulation has no + // positioned nodes) — the once-per-graph framing polls on this so a slow data + // load doesn't leave the graph pinned off-screen (the live-guest bug). + const fitViewToNodes = useCallback((): boolean => { + if (!svgRef.current || !containerRef.current) return false; // Bounds must come from the LIVE simulation objects (the React-state // nodes are different objects since the identity merge and may hold // stale/initial coordinates — that bug made fit zoom out to a huge box). const simNodes = (simulationRef.current?.nodes() as any[]) || []; - if (simNodes.length === 0) return; + if (simNodes.length === 0) return false; const svg = d3.select(svgRef.current); const container = containerRef.current; @@ -3989,7 +3992,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i maxY = Math.max(maxY, node.y + dims.height / 2); }); - if (!isFinite(minX)) return; // No valid positions + if (!isFinite(minX)) return false; // No valid positions yet const padding = 60; minX -= padding; maxX += padding; minY -= padding; maxY += padding; @@ -4011,6 +4014,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i } else { svg.call(d3.zoom().transform as any, transform); } + return true; }, [getNodeDimensions]); // Latest fit fn in a ref so the once-per-graph camera effect can call it WITHOUT // listing it as a dep — its identity churns during settle, which would otherwise @@ -4268,24 +4272,67 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // On graph load: restore the user's saved camera for THIS graph (respect their // in-session view) or auto-fit ONCE so it never loads off-screen, then jumps in. // Keyed on the graph id; never re-fits a graph already framed (don't fight the - // user). Uses fitViewRef (not fitViewToNodes) so the timer isn't cancelled by - // the callback's identity churning during physics settle. + // user). + // + // POLL until the fit/restore actually succeeds, then mark the graph done. A + // fixed delay was the live-guest bug: on a cold load the data chain (guest + // token → me → graph → workItems) lands AFTER the timer, so the simulation had + // no positioned nodes yet, fitViewToNodes no-opped, the graph was marked + // "fitted", and it stayed pinned top-left forever. We retry on a short cadence + // until fitViewRef returns true (sim has real positions), capped so we never + // spin. fitViewRef is used (not fitViewToNodes) so its identity churn during + // physics settle doesn't cancel the loop. useEffect(() => { - if (!hasNodes || !svgRef.current || !currentGraphId) return undefined; + if (!hasNodes || !currentGraphId) return undefined; if (fittedGraphsRef.current.has(currentGraphId)) return undefined; const gid = currentGraphId; - const timer = setTimeout(() => { + + let saved: { x: number; y: number; k: number } | null = null; + try { const r = localStorage.getItem(`graphdone:camera:${gid}`); saved = r ? JSON.parse(r) : null; } catch { /* ignore */ } + const hasSaved = !!(saved && typeof saved.k === 'number'); + + let timer: ReturnType | null = null; + let tries = 0; + const MAX_TRIES = 60; // ~15s at 250ms — covers a cold Worker/D1 first paint + + const attempt = () => { + timer = null; if (fittedGraphsRef.current.has(gid)) return; - fittedGraphsRef.current.add(gid); - let saved: { x: number; y: number; k: number } | null = null; - try { const r = localStorage.getItem(`graphdone:camera:${gid}`); saved = r ? JSON.parse(r) : null; } catch { /* ignore */ } - if (saved && typeof saved.k === 'number' && zoomBehaviorRef.current && svgRef.current) { - d3.select(svgRef.current).call(zoomBehaviorRef.current.transform as any, d3.zoomIdentity.translate(saved.x, saved.y).scale(saved.k)); - } else { - fitViewRef.current(); + // Graph changed out from under us — let the new graph's effect take over. + if (currentGraphIdRef.current !== gid) return; + + // The may not be mounted yet on the render where hasNodes flipped + // true (it renders a tick later). zoomBehaviorRef + the simulation are set + // up only once it mounts. Keep retrying until everything is ready rather + // than giving up — that early-return was the live bug that left the + // origin-centred graph pinned in the top-left corner. + let ok = false; + if (svgRef.current && zoomBehaviorRef.current) { + if (hasSaved) { + d3.select(svgRef.current).call( + zoomBehaviorRef.current.transform as any, + d3.zoomIdentity.translate(saved!.x, saved!.y).scale(saved!.k) + ); + ok = true; + } else { + ok = fitViewRef.current(); // false until the sim has positioned nodes + } } - }, 1200); - return () => clearTimeout(timer); + + if (ok) { + fittedGraphsRef.current.add(gid); + return; + } + if (++tries >= MAX_TRIES) { + fittedGraphsRef.current.add(gid); // give up; don't spin forever + return; + } + timer = setTimeout(attempt, 250); + }; + + // Small initial delay so the first simulation build/tick has run. + timer = setTimeout(attempt, 300); + return () => { if (timer) clearTimeout(timer); }; }, [hasNodes, currentGraphId]); // PR-3: keep the expand-in-place panel glued to its node through drags, ticks diff --git a/tests/e2e/camera.spec.ts b/tests/e2e/camera.spec.ts index 14782da5..48522d72 100644 --- a/tests/e2e/camera.spec.ts +++ b/tests/e2e/camera.spec.ts @@ -110,4 +110,32 @@ test.describe('camera framing + persistence @camera', () => { // of the same view (no fresh fit, which would change k substantially). expect(Math.abs(restored.k - cam.k), 'zoom level preserved across reload').toBeLessThan(0.05); }); + + // Regression for the live-guest bug: a cold load delivers the graph data AFTER + // a fixed fit timer would have fired, so the fit no-opped and the graph stayed + // pinned off-screen. We delay the workItems response past the old 1200ms timer + // and assert the poll-until-ready framing still centres the graph. + test('graph still frames when data arrives slowly (cold load)', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('graphdone:viewMode', 'graph')); + await login(page, TEST_USERS.ADMIN); + + // Delay only the workItems query (not login/me) by ~2s — well past the old + // fixed fit timer — to mimic a cold Worker/D1 first paint. + await page.route('**/graphql', async (route) => { + const body = route.request().postData() || ''; + if (/workItems/i.test(body)) { + await new Promise((r) => setTimeout(r, 2000)); + } + await route.continue(); + }); + + await page.goto(`${getBaseURL()}/`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.graph-container svg .node', { timeout: 20_000 }); + // Allow the delayed data + the poll-until-ready fit to complete. + await page.waitForTimeout(5000); + + const { inView, total } = await nodesInView(page); + expect(total, 'graph has nodes after the delayed load').toBeGreaterThan(0); + expect(inView, `${inView}/${total} nodes framed despite slow load`).toBeGreaterThan(total * 0.5); + }); });