From 63f6169bd66074e5013b3b7405854df79ec1e700 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 20 Aug 2026 12:57:10 +0300 Subject: [PATCH 1/5] fix: frame schema tables by rendered size when zooming The canvas' zoomToFit only considers node centres, so tall tables were clipped and a single table was always zoomed to the configured maximum regardless of its size. Compute the bounding box from the rendered card dimensions instead, and clamp the resulting zoom. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../schema/SchemaCanvasControls.tsx | 10 +- app/src/components/schema/SchemaViewer.tsx | 99 +++++++++++++++---- 2 files changed, 85 insertions(+), 24 deletions(-) diff --git a/app/src/components/schema/SchemaCanvasControls.tsx b/app/src/components/schema/SchemaCanvasControls.tsx index 3ff47d9c..ade5d707 100644 --- a/app/src/components/schema/SchemaCanvasControls.tsx +++ b/app/src/components/schema/SchemaCanvasControls.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { KeyboardEvent, RefObject } from 'react'; import type { FalkorDBCanvas, - GraphNode, HierarchyDirection, LayoutMode, RadialDirection, @@ -76,6 +75,8 @@ interface SchemaCanvasControlsProps { onFocusModeChange: (enabled: boolean) => void; selectedTableId: number | null; onSelectTable: (tableId: number | null) => void; + /** Frames the matching tables (all of them when no predicate is given). */ + onFrameNodes: (match?: (nodeId: number) => boolean) => void; } const SchemaCanvasControls = ({ @@ -86,6 +87,7 @@ const SchemaCanvasControls = ({ onFocusModeChange, selectedTableId, onSelectTable, + onFrameNodes, }: SchemaCanvasControlsProps) => { const [layout, setLayout] = useState('force'); const [direction, setDirection] = useState(''); @@ -124,9 +126,9 @@ const SchemaCanvasControls = ({ onSelectTable(table.id); setSearch(table.name); setSuggestionsOpen(false); - canvasRef.current?.zoomToFit(4, (node: GraphNode) => node.id === table.id); + onFrameNodes((nodeId) => nodeId === table.id); }, - [canvasRef, onSelectTable] + [onFrameNodes, onSelectTable] ); const clearSearch = useCallback(() => { @@ -177,7 +179,7 @@ const SchemaCanvasControls = ({ }; const handleCenter = () => { - canvasRef.current?.zoomToFit(); + onFrameNodes(); }; const applyDirection = (mode: LayoutMode, value: string) => { diff --git a/app/src/components/schema/SchemaViewer.tsx b/app/src/components/schema/SchemaViewer.tsx index 1bc1715b..ac9815f6 100644 --- a/app/src/components/schema/SchemaViewer.tsx +++ b/app/src/components/schema/SchemaViewer.tsx @@ -38,6 +38,23 @@ const HIGHLIGHT_COLOR = '#8b5cf6'; /** Opacity applied to schema elements the selected query does not touch. */ const DIMMED_OPACITY = 0.25; +// Geometry of a rendered table card, shared by the drawing code and the +// viewport framing below. +const NODE_WIDTH = 160; +const NODE_LINE_HEIGHT = 14; +const NODE_PADDING = 8; +const NODE_HEADER_HEIGHT = 20; + +const tableNodeHeight = (columnCount: number): number => + NODE_HEADER_HEIGHT + columnCount * NODE_LINE_HEIGHT + NODE_PADDING * 2; + +/** Gap in pixels kept between the framed tables and the canvas edges. */ +const FIT_PADDING_PX = 32; +/** Upper bound on the framing zoom, so a single small table is not blown up. */ +const FIT_MAX_ZOOM = 1.5; +const FIT_MIN_ZOOM = 0.05; +const FIT_ANIMATION_MS = 300; + /** Link endpoints are ids before the layout runs and node objects afterwards. */ const endpointId = (endpoint: unknown): string => { if (endpoint && typeof endpoint === 'object' && 'id' in endpoint) { @@ -176,7 +193,6 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch return () => observer.disconnect(); }, []); - const NODE_WIDTH = 160; const MIN_WIDTH = 300; const MAX_WIDTH_PERCENT = 0.6; const DEFAULT_WIDTH_PERCENT = 0.5; @@ -290,15 +306,62 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch return theme === 'light' ? '#9ca3af' : '#4b5563'; }, [theme, hasHighlight, highlightedLinkKeys]); + // The canvas' own zoomToFit frames node centres, so a tall table gets clipped + // and a single table is zoomed to the configured maximum whatever its size. + // Frame the rendered cards instead. + const frameNodes = useCallback((match?: (nodeId: number) => boolean) => { + const canvas = canvasRef.current; + + if (!canvas || !schemaData) return; + + const rect = canvas.getBoundingClientRect(); + + if (!rect.width || !rect.height) return; + + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + + canvas.getGraphData()?.nodes.forEach((node) => { + if (node.x === undefined || node.y === undefined) return; + if (match && !match(Number(node.id))) return; + + const columns = schemaData.nodesMap.get(Number(node.id))?.columns ?? []; + const halfHeight = tableNodeHeight(columns.length) / 2; + + minX = Math.min(minX, node.x - NODE_WIDTH / 2); + maxX = Math.max(maxX, node.x + NODE_WIDTH / 2); + minY = Math.min(minY, node.y - halfHeight); + maxY = Math.max(maxY, node.y + halfHeight); + }); + + // Nothing matched, or the layout has not produced coordinates yet. + if (!Number.isFinite(minX)) return; + + const fitZoom = Math.min( + (rect.width - FIT_PADDING_PX * 2) / (maxX - minX), + (rect.height - FIT_PADDING_PX * 2) / (maxY - minY) + ); + + canvas.centerAt((minX + maxX) / 2, (minY + maxY) / 2, FIT_ANIMATION_MS); + + const zoom = Math.min(Math.max(fitZoom, FIT_MIN_ZOOM), FIT_MAX_ZOOM); + // The canvas' own zoom() is instant, so go through force-graph to keep the + // pan and the zoom on the same animation. + const graph = canvas.getGraph(); + + if (graph) { + graph.zoom(zoom, FIT_ANIMATION_MS); + } else { + canvas.zoom(zoom); + } + }, [schemaData]); + // Convert schema data to canvas format const convertToCanvasData = useCallback((data: SchemaData): Data => { const nodes = data.nodes.map((node) => { - // Calculate node size based on height (same calculation as in nodeCanvasObject) - const columns = node.columns || []; - const lineHeight = 14; - const padding = 8; - const headerHeight = 20; - const nodeHeight = headerHeight + columns.length * lineHeight + padding * 2; + const nodeHeight = tableNodeHeight((node.columns || []).length); // Use the larger dimension as collision radius (in pixels) const size = Math.max(NODE_WIDTH / 2, nodeHeight / 2); @@ -340,9 +403,9 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch if (!canvas || !canvasLoaded || !schemaData) return; const nodeCanvasObject = (node: GraphNode, ctx: CanvasRenderingContext2D) => { - const lineHeight = 14; - const padding = 8; - const headerHeight = 20; + const lineHeight = NODE_LINE_HEIGHT; + const padding = NODE_PADDING; + const headerHeight = NODE_HEADER_HEIGHT; const fontSize = 12; // Theme-aware colors @@ -364,7 +427,7 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch const columns = schemaNode.columns || []; - const nodeHeight = headerHeight + columns.length * lineHeight + padding * 2; + const nodeHeight = tableNodeHeight(columns.length); const previousAlpha = ctx.globalAlpha; if (isDimmed) { @@ -448,10 +511,7 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch if (!schemaNode) return; const columns = schemaNode.columns || []; - const lineHeight = 14; - const padding = 8; - const headerHeight = 20; - const nodeHeight = headerHeight + columns.length * lineHeight + padding * 2; + const nodeHeight = tableNodeHeight(columns.length); ctx.fillStyle = color; const areaPadding = 5; @@ -522,16 +582,14 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch // Bring the highlighted tables into view when a query is selected useEffect(() => { - const canvas = canvasRef.current; - - if (!canvas || !canvasLoaded || !hasHighlight) return; + if (!canvasLoaded || !hasHighlight) return; const timer = setTimeout(() => { - canvas.zoomToFit(1.5, (node: GraphNode) => highlightedNodeIds.has(node.id)); + frameNodes((nodeId) => highlightedNodeIds.has(nodeId)); }, HIGHLIGHT_ZOOM_DELAY_MS); return () => clearTimeout(timer); - }, [canvasLoaded, hasHighlight, highlightedNodeIds]); + }, [canvasLoaded, hasHighlight, highlightedNodeIds, frameNodes]); if (!isOpen) return null; @@ -580,6 +638,7 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch onFocusModeChange={setFocusMode} selectedTableId={selectedTableId} onSelectTable={setSelectedTableId} + onFrameNodes={frameNodes} /> {/* Highlight status */} From 375658e77f3d6eec4870378b3821615c415a82f6 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 20 Aug 2026 13:07:18 +0300 Subject: [PATCH 2/5] fix: retry highlight framing until the layout has coordinates Also clamp the available fit area so a panel narrower than the padding cannot ask for a negative width or height. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/src/components/schema/SchemaViewer.tsx | 39 ++++++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/app/src/components/schema/SchemaViewer.tsx b/app/src/components/schema/SchemaViewer.tsx index ac9815f6..01388b3a 100644 --- a/app/src/components/schema/SchemaViewer.tsx +++ b/app/src/components/schema/SchemaViewer.tsx @@ -70,6 +70,8 @@ const linkKey = (source: unknown, target: unknown): string => // Must stay above the canvas' `interaction.zoomToFitDelay` (50ms default) so the // highlight framing is applied after the canvas' own initial fit. const HIGHLIGHT_ZOOM_DELAY_MS = 100; +/** How long to keep retrying the highlight framing while the layout settles. */ +const HIGHLIGHT_ZOOM_TIMEOUT_MS = 2000; const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: SchemaViewerProps) => { const canvasRef = useRef(null); @@ -309,14 +311,14 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch // The canvas' own zoomToFit frames node centres, so a tall table gets clipped // and a single table is zoomed to the configured maximum whatever its size. // Frame the rendered cards instead. - const frameNodes = useCallback((match?: (nodeId: number) => boolean) => { + const frameNodes = useCallback((match?: (nodeId: number) => boolean): boolean => { const canvas = canvasRef.current; - if (!canvas || !schemaData) return; + if (!canvas || !schemaData) return false; const rect = canvas.getBoundingClientRect(); - if (!rect.width || !rect.height) return; + if (!rect.width || !rect.height) return false; let minX = Infinity; let maxX = -Infinity; @@ -337,11 +339,12 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch }); // Nothing matched, or the layout has not produced coordinates yet. - if (!Number.isFinite(minX)) return; + if (!Number.isFinite(minX)) return false; + // A panel narrower than the padding would otherwise ask for a negative area. const fitZoom = Math.min( - (rect.width - FIT_PADDING_PX * 2) / (maxX - minX), - (rect.height - FIT_PADDING_PX * 2) / (maxY - minY) + Math.max(rect.width - FIT_PADDING_PX * 2, 1) / (maxX - minX), + Math.max(rect.height - FIT_PADDING_PX * 2, 1) / (maxY - minY) ); canvas.centerAt((minX + maxX) / 2, (minY + maxY) / 2, FIT_ANIMATION_MS); @@ -356,6 +359,8 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch } else { canvas.zoom(zoom); } + + return true; }, [schemaData]); // Convert schema data to canvas format @@ -580,15 +585,27 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch canvas.setGraphData(canvasData); }, [schemaData, canvasLoaded, convertToCanvasData]); - // Bring the highlighted tables into view when a query is selected + // Bring the highlighted tables into view when a query is selected. A slow + // layout may not have produced coordinates yet, so keep retrying until it has. useEffect(() => { if (!canvasLoaded || !hasHighlight) return; - const timer = setTimeout(() => { - frameNodes((nodeId) => highlightedNodeIds.has(nodeId)); - }, HIGHLIGHT_ZOOM_DELAY_MS); + let frame = 0; + const deadline = Date.now() + HIGHLIGHT_ZOOM_TIMEOUT_MS; + + const attempt = () => { + if (frameNodes((nodeId) => highlightedNodeIds.has(nodeId))) return; + if (Date.now() >= deadline) return; + + frame = requestAnimationFrame(attempt); + }; - return () => clearTimeout(timer); + const timer = setTimeout(attempt, HIGHLIGHT_ZOOM_DELAY_MS); + + return () => { + clearTimeout(timer); + cancelAnimationFrame(frame); + }; }, [canvasLoaded, hasHighlight, highlightedNodeIds, frameNodes]); if (!isOpen) return null; From f6dc175546ed8933478a70a0128ff89ed87ae1f3 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 20 Aug 2026 13:19:10 +0300 Subject: [PATCH 3/5] fix: skip highlight framing while the schema viewer is closed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/src/components/schema/SchemaViewer.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/components/schema/SchemaViewer.tsx b/app/src/components/schema/SchemaViewer.tsx index 01388b3a..3f984dce 100644 --- a/app/src/components/schema/SchemaViewer.tsx +++ b/app/src/components/schema/SchemaViewer.tsx @@ -587,8 +587,9 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch // Bring the highlighted tables into view when a query is selected. A slow // layout may not have produced coordinates yet, so keep retrying until it has. + // Reopening the viewer re-runs this, since the canvas is unmounted while closed. useEffect(() => { - if (!canvasLoaded || !hasHighlight) return; + if (!isOpen || !canvasLoaded || !hasHighlight) return; let frame = 0; const deadline = Date.now() + HIGHLIGHT_ZOOM_TIMEOUT_MS; @@ -606,7 +607,7 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch clearTimeout(timer); cancelAnimationFrame(frame); }; - }, [canvasLoaded, hasHighlight, highlightedNodeIds, frameNodes]); + }, [isOpen, canvasLoaded, hasHighlight, highlightedNodeIds, frameNodes]); if (!isOpen) return null; From d5e5c8fdf4b05b944dc7744691a786bc40774c71 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Wed, 26 Aug 2026 15:40:43 +0300 Subject: [PATCH 4/5] fix: re-frame the schema after a layout switch, and wait for the layout to settle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching layout or direction ends in the canvas' own applyLayout(), which schedules the centre-based zoomToFit this PR replaces. A tall table was clipped again two clicks after the Center button had framed it correctly, and an active query highlight lost its framing without the highlight effect re-running. Both paths now ask the viewer to re-frame once the new layout has settled, keeping the highlighted tables as the target when a query is selected. The highlight retry loop also exited as soon as any matched node had an x/y, which the synchronous force warmup guarantees well before the 100ms delay — so it always framed on its first attempt. While the layout is still moving (for example after re-enabling animation) that target is stale and the tables drift out of view. Frame when the bounding box stops changing instead, falling back to a best-effort frame at the 2s deadline for a perpetually moving layout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../schema/SchemaCanvasControls.tsx | 10 ++ app/src/components/schema/SchemaViewer.tsx | 116 +++++++++++++----- 2 files changed, 98 insertions(+), 28 deletions(-) diff --git a/app/src/components/schema/SchemaCanvasControls.tsx b/app/src/components/schema/SchemaCanvasControls.tsx index ade5d707..cb36eeda 100644 --- a/app/src/components/schema/SchemaCanvasControls.tsx +++ b/app/src/components/schema/SchemaCanvasControls.tsx @@ -77,6 +77,12 @@ interface SchemaCanvasControlsProps { onSelectTable: (tableId: number | null) => void; /** Frames the matching tables (all of them when no predicate is given). */ onFrameNodes: (match?: (nodeId: number) => boolean) => void; + /** + * Re-frames the current view once the new layout has settled. The canvas runs + * its own centre-based fit after a layout change, which clips tall cards and + * discards any highlight framing. + */ + onLayoutChanged: () => void; } const SchemaCanvasControls = ({ @@ -88,6 +94,7 @@ const SchemaCanvasControls = ({ selectedTableId, onSelectTable, onFrameNodes, + onLayoutChanged, }: SchemaCanvasControlsProps) => { const [layout, setLayout] = useState('force'); const [direction, setDirection] = useState(''); @@ -216,12 +223,15 @@ const SchemaCanvasControls = ({ setAnimation(false); canvasRef.current?.setAnimation(false); } + + onLayoutChanged(); }; const handleDirectionChange = (value: string, targetLayout: LayoutMode) => { directionsRef.current = { ...directionsRef.current, [targetLayout]: value }; setDirection(value); applyDirection(targetLayout, value); + onLayoutChanged(); }; const handleAnimationToggle = (checked: boolean) => { diff --git a/app/src/components/schema/SchemaViewer.tsx b/app/src/components/schema/SchemaViewer.tsx index 3f984dce..740d43bc 100644 --- a/app/src/components/schema/SchemaViewer.tsx +++ b/app/src/components/schema/SchemaViewer.tsx @@ -70,12 +70,30 @@ const linkKey = (source: unknown, target: unknown): string => // Must stay above the canvas' `interaction.zoomToFitDelay` (50ms default) so the // highlight framing is applied after the canvas' own initial fit. const HIGHLIGHT_ZOOM_DELAY_MS = 100; -/** How long to keep retrying the highlight framing while the layout settles. */ -const HIGHLIGHT_ZOOM_TIMEOUT_MS = 2000; +/** How long to keep waiting for the layout to settle before framing anyway. */ +const SETTLE_TIMEOUT_MS = 2000; +/** World-unit movement below which the layout counts as settled. */ +const SETTLE_EPSILON = 0.5; + +interface Bounds { + minX: number; + maxX: number; + minY: number; + maxY: number; +} + +const boundsSettled = (a: Bounds, b: Bounds): boolean => + Math.abs(a.minX - b.minX) < SETTLE_EPSILON && + Math.abs(a.maxX - b.maxX) < SETTLE_EPSILON && + Math.abs(a.minY - b.minY) < SETTLE_EPSILON && + Math.abs(a.maxY - b.maxY) < SETTLE_EPSILON; const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: SchemaViewerProps) => { const canvasRef = useRef(null); const resizeRef = useRef(null); + // Handle of the in-flight "wait for the layout to settle" loop, so a new + // framing request cancels the previous one instead of racing it. + const settleFrameRef = useRef(0); // Schema snapshot currently seeded into the canvas, used to avoid re-seeding // (and losing node positions) when only the highlight changed. const renderedSchemaRef = useRef(null); @@ -308,17 +326,12 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch return theme === 'light' ? '#9ca3af' : '#4b5563'; }, [theme, hasHighlight, highlightedLinkKeys]); - // The canvas' own zoomToFit frames node centres, so a tall table gets clipped - // and a single table is zoomed to the configured maximum whatever its size. - // Frame the rendered cards instead. - const frameNodes = useCallback((match?: (nodeId: number) => boolean): boolean => { + // Bounding box of the matching table cards in world units, or null when the + // layout has not produced coordinates for any of them yet. + const nodeBounds = useCallback((match?: (nodeId: number) => boolean): Bounds | null => { const canvas = canvasRef.current; - if (!canvas || !schemaData) return false; - - const rect = canvas.getBoundingClientRect(); - - if (!rect.width || !rect.height) return false; + if (!canvas || !schemaData) return null; let minX = Infinity; let maxX = -Infinity; @@ -339,7 +352,28 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch }); // Nothing matched, or the layout has not produced coordinates yet. - if (!Number.isFinite(minX)) return false; + if (!Number.isFinite(minX)) return null; + + return { minX, maxX, minY, maxY }; + }, [schemaData]); + + // The canvas' own zoomToFit frames node centres, so a tall table gets clipped + // and a single table is zoomed to the configured maximum whatever its size. + // Frame the rendered cards instead. + const frameNodes = useCallback((match?: (nodeId: number) => boolean): boolean => { + const canvas = canvasRef.current; + + if (!canvas) return false; + + const rect = canvas.getBoundingClientRect(); + + if (!rect.width || !rect.height) return false; + + const bounds = nodeBounds(match); + + if (!bounds) return false; + + const { minX, maxX, minY, maxY } = bounds; // A panel narrower than the padding would otherwise ask for a negative area. const fitZoom = Math.min( @@ -361,7 +395,40 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch } return true; - }, [schemaData]); + }, [nodeBounds]); + + // A running layout keeps moving after it has produced its first coordinates, + // so framing them aims at an already-stale target. Wait for the bounds to stop + // changing. Only one wait runs at a time: a later request supersedes an + // earlier one rather than racing it. + const frameWhenSettled = useCallback((match?: (nodeId: number) => boolean) => { + cancelAnimationFrame(settleFrameRef.current); + + const deadline = Date.now() + SETTLE_TIMEOUT_MS; + let previous: Bounds | null = null; + + const attempt = () => { + const bounds = nodeBounds(match); + + if ((bounds && previous && boundsSettled(bounds, previous)) || Date.now() >= deadline) { + frameNodes(match); + return; + } + + previous = bounds; + settleFrameRef.current = requestAnimationFrame(attempt); + }; + + settleFrameRef.current = requestAnimationFrame(attempt); + }, [nodeBounds, frameNodes]); + + // Switching layout or direction re-runs the canvas' own centre-based fit, + // which clips tall cards and discards any highlight framing. + const frameCurrentTarget = useCallback(() => { + frameWhenSettled( + hasHighlight ? (nodeId: number) => highlightedNodeIds.has(nodeId) : undefined + ); + }, [frameWhenSettled, hasHighlight, highlightedNodeIds]); // Convert schema data to canvas format const convertToCanvasData = useCallback((data: SchemaData): Data => { @@ -585,29 +652,21 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch canvas.setGraphData(canvasData); }, [schemaData, canvasLoaded, convertToCanvasData]); - // Bring the highlighted tables into view when a query is selected. A slow - // layout may not have produced coordinates yet, so keep retrying until it has. + // Bring the highlighted tables into view when a query is selected. The layout + // may still be moving, so frame it once it has stopped. // Reopening the viewer re-runs this, since the canvas is unmounted while closed. useEffect(() => { if (!isOpen || !canvasLoaded || !hasHighlight) return; - let frame = 0; - const deadline = Date.now() + HIGHLIGHT_ZOOM_TIMEOUT_MS; - - const attempt = () => { - if (frameNodes((nodeId) => highlightedNodeIds.has(nodeId))) return; - if (Date.now() >= deadline) return; - - frame = requestAnimationFrame(attempt); - }; - - const timer = setTimeout(attempt, HIGHLIGHT_ZOOM_DELAY_MS); + const timer = setTimeout(() => { + frameWhenSettled((nodeId) => highlightedNodeIds.has(nodeId)); + }, HIGHLIGHT_ZOOM_DELAY_MS); return () => { clearTimeout(timer); - cancelAnimationFrame(frame); + cancelAnimationFrame(settleFrameRef.current); }; - }, [isOpen, canvasLoaded, hasHighlight, highlightedNodeIds, frameNodes]); + }, [isOpen, canvasLoaded, hasHighlight, highlightedNodeIds, frameWhenSettled]); if (!isOpen) return null; @@ -657,6 +716,7 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch selectedTableId={selectedTableId} onSelectTable={setSelectedTableId} onFrameNodes={frameNodes} + onLayoutChanged={frameCurrentTarget} /> {/* Highlight status */} From c96541d1587edcafe3444b09253c3809722bdc0b Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Wed, 26 Aug 2026 15:51:20 +0300 Subject: [PATCH 5/5] fix: stop the settle wait once the schema viewer has closed Closing the viewer unmounts the canvas, but a pending frameWhenSettled loop kept rescheduling until its 2s deadline. Bail out as soon as the canvas ref is empty. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/src/components/schema/SchemaViewer.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/components/schema/SchemaViewer.tsx b/app/src/components/schema/SchemaViewer.tsx index 740d43bc..cd8780e0 100644 --- a/app/src/components/schema/SchemaViewer.tsx +++ b/app/src/components/schema/SchemaViewer.tsx @@ -408,6 +408,9 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch let previous: Bounds | null = null; const attempt = () => { + // The viewer can close mid-wait, which unmounts the canvas. + if (!canvasRef.current) return; + const bounds = nodeBounds(match); if ((bounds && previous && boundsSettled(bounds, previous)) || Date.now() >= deadline) {