From 200a026786257b616ad9b5541996c189ab3846b0 Mon Sep 17 00:00:00 2001 From: Adrian Vogelsgesang Date: Mon, 24 Aug 2026 18:54:02 +0000 Subject: [PATCH] Prevent query graph flickering during relayout So far, there was some flickering of the edge labels. This was caused because we recreated the node objects we passed into reactflow and reset the `measured` dimensions. Therefore, reactflow re-measured the nodes and while doing so hid the labels. We now preserve React Flow's measured dimensions across layout updates so existing nodes remain initialized, We use React Flow's dimension changes as the sole measurement source and remove the parallel ResizeObserver. Furthermore, each QueryGraph now owns its rendering store, decoupling expansion, subtree, and measurement state between multiple graph instances on the same page. --- pnpm-lock.yaml | 22 ++----- query-graphs/README.md | 8 +-- query-graphs/package.json | 1 - query-graphs/src/ui/QueryGraph.tsx | 101 ++++++++++++++--------------- query-graphs/src/ui/QueryNode.css | 3 - query-graphs/src/ui/QueryNode.tsx | 29 ++------- query-graphs/src/ui/store.ts | 98 +++++++++++++--------------- query-graphs/src/ui/tree-layout.ts | 31 +++------ 8 files changed, 120 insertions(+), 173 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 214df98e..743a1940 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,7 +46,7 @@ importers: dependencies: '@xyflow/react': specifier: ^12.11.3 - version: 12.11.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 12.11.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) classcat: specifier: ^5.0.4 version: 5.0.5 @@ -56,9 +56,6 @@ importers: d3-hierarchy: specifier: ^3.1.2 version: 3.1.2 - immer: - specifier: ^11.1.18 - version: 11.1.18 react: specifier: ^19.2.8 version: 19.2.8 @@ -67,7 +64,7 @@ importers: version: 19.2.8(react@19.2.8) zustand: specifier: ^5.0.0 - version: 5.0.15(@types/react@19.2.18)(immer@11.1.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) + version: 5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) devDependencies: '@types/d3-hierarchy': specifier: ^3.1.2 @@ -2574,9 +2571,6 @@ packages: resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} - immer@11.1.18: - resolution: {integrity: sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==} - import-local@3.2.0: resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} engines: {node: '>=8'} @@ -5641,13 +5635,13 @@ snapshots: '@xtuc/long@4.2.2': {} - '@xyflow/react@12.11.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@xyflow/react@12.11.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@xyflow/system': 0.0.80 classcat: 5.0.5 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - zustand: 4.5.7(@types/react@19.2.18)(immer@11.1.18)(react@19.2.8) + zustand: 4.5.7(@types/react@19.2.18)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) @@ -6845,8 +6839,6 @@ snapshots: ignore@7.0.6: {} - immer@11.1.18: {} - import-local@3.2.0: dependencies: pkg-dir: 4.2.0 @@ -8511,17 +8503,15 @@ snapshots: zod@4.4.3: {} - zustand@4.5.7(@types/react@19.2.18)(immer@11.1.18)(react@19.2.8): + zustand@4.5.7(@types/react@19.2.18)(react@19.2.8): dependencies: use-sync-external-store: 1.6.0(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - immer: 11.1.18 react: 19.2.8 - zustand@5.0.15(@types/react@19.2.18)(immer@11.1.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): + zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): optionalDependencies: '@types/react': 19.2.18 - immer: 11.1.18 react: 19.2.8 use-sync-external-store: 1.6.0(react@19.2.8) diff --git a/query-graphs/README.md b/query-graphs/README.md index 9c5c34da..aa2b8815 100644 --- a/query-graphs/README.md +++ b/query-graphs/README.md @@ -48,10 +48,10 @@ The library intentionally exposes low-level loaders (`json`, `xml`) as generic f ## The Renderer `QueryGraph` (`src/ui/QueryGraph.tsx`) is the top-level component rendering a `TreeDescription`. -It assigns a stable id to every node, seeds the interaction store from each node's `expandedByDefault` flag, and observes on-screen node sizes with a `ResizeObserver`. +It assigns a stable id to every node, creates a graph-local rendering store seeded from each node's `expandedByDefault` flag, and retains the node dimensions measured by react-flow. `tree-layout.ts` positions the tree with [`d3-flextree`](https://github.com/Klortho/d3-flextree) on top of `d3-hierarchy`, then translates the result into react-flow nodes and edges. -Layout is driven by the **measured** DOM size of each node, so it runs in two passes: the first render uses a placeholder size and, once the `ResizeObserver` reports real dimensions, the tree re-lays-out with the correct sizes. +Layout is driven by the **measured** DOM size of each node, so it runs in two passes: react-flow measures new nodes after their first render, then the tree re-lays-out with the correct sizes. Those measurements are retained in the controlled node objects so react-flow does not re-initialize them on every layout. Edge thickness is scaled from `edgeWidth`, and `crosslinks` are added as extra edges. `QueryNode` (`src/ui/QueryNode.tsx`) draws a single node. @@ -83,12 +83,12 @@ These are the touches that make a plan readable at a glance: ### Interaction State -`store.ts` is a [Zustand](https://github.com/pmndrs/zustand) store (with the `immer` and `devtools` middleware) holding all mutable view state, so React components stay purely declarative. +Each `QueryGraph` owns a [Zustand](https://github.com/pmndrs/zustand) store holding its mutable rendering state, so multiple graphs do not interfere with one another. It tracks three things, and the distinction between the first two is the key subtlety: * `expandedNodes` — which nodes have their **property detail panel** open. * `expandedSubtrees` — which nodes reveal their **`collapsedChildren`** in the graph. -* `nodeDimensions` — the measured head/body size of each node, fed back into layout. +* `nodeDimensions` — react-flow's measurements, retained across controlled-node layout updates. ## Tech Debt diff --git a/query-graphs/package.json b/query-graphs/package.json index 228bc42a..f3c54a25 100644 --- a/query-graphs/package.json +++ b/query-graphs/package.json @@ -47,7 +47,6 @@ "classcat": "^5.0.4", "d3-flextree": "^2.1.2", "d3-hierarchy": "^3.1.2", - "immer": "^11.1.18", "react": "^19.2.8", "react-dom": "^19.2.8", "zustand": "^5.0.0" diff --git a/query-graphs/src/ui/QueryGraph.tsx b/query-graphs/src/ui/QueryGraph.tsx index c5a50f31..de74d5cf 100644 --- a/query-graphs/src/ui/QueryGraph.tsx +++ b/query-graphs/src/ui/QueryGraph.tsx @@ -1,3 +1,4 @@ +import type {NodeChange} from "@xyflow/react"; import {ReactFlow, MiniMap, Controls, ReactFlowProvider} from "@xyflow/react"; import "@xyflow/react/dist/base.css"; @@ -5,11 +6,11 @@ import {layoutTree} from "./tree-layout"; import type {TreeDescription, TreeNode} from "../tree-description"; import {allChildren, visitTreeNodes} from "../tree-description"; import type {ReactNode} from "react"; -import {useMemo, useEffect, useRef} from "react"; +import {useCallback, useMemo} from "react"; import {QueryNode} from "./QueryNode"; import type {QueryGraphNode} from "./QueryNode"; import {ColoredEdge} from "./ColoredEdge"; -import {useGraphRenderingStore} from "./store"; +import {createGraphRenderingStore, GraphRenderingStoreContext, useGraphRenderingStore} from "./store"; import "./QueryGraph.css"; interface QueryGraphProps { @@ -17,6 +18,10 @@ interface QueryGraphProps { children: ReactNode | ReactNode[]; } +interface QueryGraphInternalProps extends QueryGraphProps { + nodeIdMapping: Map; +} + function minimapNodeColor(n: QueryGraphNode): string { if (n.data.nodeColor) return n.data.nodeColor; if (n.data.iconColor) return n.data.iconColor; @@ -31,59 +36,27 @@ const edgeTypes = { colored: ColoredEdge, }; -function QueryGraphInternal({treeDescription, children}: QueryGraphProps) { - // Assign ids to all nodes - const nodeIdMapping = useMemo(() => { - let nextId = 0; - const nodeIds = new Map(); - visitTreeNodes( - treeDescription.root, - (d) => { - nodeIds.set(d, "" + nextId++); - }, - allChildren, - ); - return nodeIds; - }, [treeDescription]); - - // Initialize our state using the correct "expandedByDefault" state - const initGraphStore = useGraphRenderingStore((s) => s.init); - useMemo(() => { - const expandedSubtrees = {}; - visitTreeNodes( - treeDescription.root, - (n) => { - if (n.expandedByDefault) { - expandedSubtrees[nodeIdMapping.get(n)!] = true; - } - }, - allChildren, - ); - initGraphStore(expandedSubtrees); - }, [treeDescription, initGraphStore, nodeIdMapping]); - - // Create a ResizeObserver to keep track of the sizes of the nodes - const resizeObserverRef = useRef(undefined); +function QueryGraphInternal({treeDescription, children, nodeIdMapping}: QueryGraphInternalProps) { + // Keep React Flow's measurements in the controlled node objects. Dropping them when + // recomputing the layout makes React Flow repeatedly hide and re-initialize the nodes. + const nodeDimensions = useGraphRenderingStore((s) => s.nodeDimensions); const updateNodeDimensions = useGraphRenderingStore((s) => s.updateNodeDimensions); - const resizeObserver = useMemo(() => { - resizeObserverRef.current?.disconnect(); - const observer = new ResizeObserver(updateNodeDimensions); - resizeObserverRef.current = observer; - return observer; - }, [updateNodeDimensions]); - useEffect(() => { - return () => { - resizeObserverRef.current?.disconnect(); - }; - }, []); + const onNodesChange = useCallback( + (changes: NodeChange[]) => { + const updates = changes.flatMap((change) => { + if (change.type !== "dimensions" || change.dimensions === undefined) return []; + return [[change.id, change.dimensions] as const]; + }); + updateNodeDimensions(updates); + }, + [updateNodeDimensions], + ); - // Layout the tree, using the actual measured sizes of the DOM nodes - const nodeDimensions = useGraphRenderingStore((s) => s.nodeDimensions); - const expandedNodes = useGraphRenderingStore((s) => s.expandedNodes); + // Layout the tree using the dimensions measured by React Flow itself. const expandedSubtrees = useGraphRenderingStore((s) => s.expandedSubtrees); const layout = useMemo( - () => layoutTree(treeDescription, nodeIdMapping, nodeDimensions, expandedNodes, expandedSubtrees, resizeObserver), - [treeDescription, nodeIdMapping, nodeDimensions, expandedNodes, expandedSubtrees, resizeObserver], + () => layoutTree(treeDescription, nodeIdMapping, nodeDimensions, expandedSubtrees), + [treeDescription, nodeIdMapping, nodeDimensions, expandedSubtrees], ); return ( @@ -93,6 +66,7 @@ function QueryGraphInternal({treeDescription, children}: QueryGraphProps) { nodeOrigin={[0.5, 0]} nodeTypes={nodeTypes} edgeTypes={edgeTypes} + onNodesChange={onNodesChange} fitView minZoom={0.2} maxZoom={1.5} @@ -109,10 +83,33 @@ function QueryGraphInternal({treeDescription, children}: QueryGraphProps) { ); } +function createGraphState(treeDescription: TreeDescription) { + let nextId = 0; + const nodeIdMapping = new Map(); + const expandedSubtrees: Record = {}; + visitTreeNodes( + treeDescription.root, + (node) => { + const id = "" + nextId++; + nodeIdMapping.set(node, id); + if (node.expandedByDefault) expandedSubtrees[id] = true; + }, + allChildren, + ); + return { + nodeIdMapping, + graphStore: createGraphRenderingStore(expandedSubtrees), + }; +} + export function QueryGraph(props: QueryGraphProps) { + const {nodeIdMapping, graphStore} = useMemo(() => createGraphState(props.treeDescription), [props.treeDescription]); + return ( - + + + ); } diff --git a/query-graphs/src/ui/QueryNode.css b/query-graphs/src/ui/QueryNode.css index 75413326..51fdf8e0 100644 --- a/query-graphs/src/ui/QueryNode.css +++ b/query-graphs/src/ui/QueryNode.css @@ -46,9 +46,6 @@ } .qg-graph-node-head { - /* We don't want the node to automatically stretch/shrink with its container - * because that would trigger the `ResizeObserver` to often and would cause too - * many layout recomputations.*/ width: max-content; margin: auto; text-align: center; diff --git a/query-graphs/src/ui/QueryNode.tsx b/query-graphs/src/ui/QueryNode.tsx index 4849da8d..247f1a2c 100644 --- a/query-graphs/src/ui/QueryNode.tsx +++ b/query-graphs/src/ui/QueryNode.tsx @@ -1,5 +1,5 @@ -import type {ReactElement, MouseEvent, RefObject} from "react"; -import {memo, useCallback, useRef, useEffect} from "react"; +import type {ReactElement, MouseEvent} from "react"; +import {memo, useCallback} from "react"; import type {Node, NodeProps} from "@xyflow/react"; import {Handle, Position} from "@xyflow/react"; import cc from "classcat"; @@ -7,27 +7,10 @@ import type {TreeNode} from "../tree-description"; import {NodeIcon} from "./NodeIcon"; import "./QueryNode.css"; import {useGraphRenderingStore} from "./store"; -import {assert} from "../assert"; -type NodeData = TreeNode & {resizeObserver: ResizeObserver}; - -export type QueryGraphNode = Node; - -function useResizeObservedRef(resizeObserver: ResizeObserver): RefObject { - const ref = useRef(null); - useEffect(() => { - assert(ref.current !== null); - const currNode = ref.current; - resizeObserver.observe(currNode); - return () => resizeObserver.unobserve(currNode); - }, [resizeObserver]); - return ref; -} +export type QueryGraphNode = Node; function QueryNode({data, id}: NodeProps) { - const bodyRef = useResizeObservedRef(data.resizeObserver); - const headRef = useResizeObservedRef(data.resizeObserver); - const expanded = useGraphRenderingStore((s) => s.expandedNodes[id]); const toggleNode = useGraphRenderingStore((s) => s.toggleExpandedNode); const subtreeExpanded = useGraphRenderingStore((s) => s.expandedSubtrees[id]); @@ -93,7 +76,7 @@ function QueryNode({data, id}: NodeProps) { <>
-
+
{colorBar(data.barsAbove, "above")}
@@ -101,9 +84,7 @@ function QueryNode({data, id}: NodeProps) {
-
- {children} -
+
{children}
{colorBar(data.barsBelow, "below")}
diff --git a/query-graphs/src/ui/store.ts b/query-graphs/src/ui/store.ts index 2286da29..70ae4c29 100644 --- a/query-graphs/src/ui/store.ts +++ b/query-graphs/src/ui/store.ts @@ -1,69 +1,63 @@ -import {create} from "zustand"; -import {immer} from "zustand/middleware/immer"; +import type {Dimensions} from "@xyflow/react"; +import {createContext, useContext} from "react"; +import {useStore} from "zustand"; import {devtools} from "zustand/middleware"; +import {createStore} from "zustand/vanilla"; +import type {StoreApi} from "zustand/vanilla"; +import {assertNotNull} from "../assert"; -export interface NodeDimensions { - headWidth?: number; - headHeight?: number; - bodyWidth?: number; - bodyHeight?: number; -} - -interface GraphRenderingState { - init: (expandedSubtrees: Record) => void; +export interface GraphRenderingState { // `expandedNodes` tracks which nodes show their property detail panel (toggled by a plain click). expandedNodes: Record; toggleExpandedNode: (nodeId: string) => void; // `expandedSubtrees` tracks which nodes reveal their `collapsedChildren` (toggled by shift-click or the +/- handle). expandedSubtrees: Record; toggleExpandedSubtree: (nodeId: string) => void; - // Measured on-screen head/body sizes, reported by a ResizeObserver and fed back into layout. - nodeDimensions: Record; - updateNodeDimensions: (entries: ResizeObserverEntry[]) => unknown; + nodeDimensions: Map; + updateNodeDimensions: (updates: readonly (readonly [string, Dimensions])[]) => void; } -export const useGraphRenderingStore = create()( - devtools( - immer((set, get) => ({ +export type GraphRenderingStore = StoreApi; + +export function createGraphRenderingStore(expandedSubtrees: Record): GraphRenderingStore { + return createStore()( + devtools((set) => ({ expandedNodes: {}, - expandedSubtrees: {}, - nodeDimensions: {}, - init: (expandedSubtrees) => { - set((state) => { - state.expandedNodes = {}; - state.expandedSubtrees = expandedSubtrees; - state.nodeDimensions = {}; - }); - }, + expandedSubtrees, toggleExpandedNode: (nodeId) => - set((state) => { - state.expandedNodes[nodeId] = !get().expandedNodes[nodeId]; - }), + set((state) => ({ + expandedNodes: { + ...state.expandedNodes, + [nodeId]: !state.expandedNodes[nodeId], + }, + })), toggleExpandedSubtree: (nodeId) => + set((state) => ({ + expandedSubtrees: { + ...state.expandedSubtrees, + [nodeId]: !state.expandedSubtrees[nodeId], + }, + })), + nodeDimensions: new Map(), + updateNodeDimensions: (updates) => set((state) => { - state.expandedSubtrees[nodeId] = !get().expandedSubtrees[nodeId]; - }), - updateNodeDimensions: (entries: ResizeObserverEntry[]) => - set((state) => { - for (const e of entries) { - // Figure out which node was changed - const target = e.target as HTMLElement; - const id = target.closest(".react-flow__node")?.getAttribute("data-id"); - if (id === null || id === undefined) continue; - // Create an entry for this node, if we don't have it, yet - if (!state.nodeDimensions[id]) { - state.nodeDimensions[id] = {}; - } - // Update head/body dimensions - if (target.classList.contains("qg-graph-node-head")) { - state.nodeDimensions[id].headWidth = target.offsetWidth; - state.nodeDimensions[id].headHeight = target.offsetHeight; - } else if (target.classList.contains("qg-graph-node-body")) { - state.nodeDimensions[id].bodyWidth = target.offsetWidth; - state.nodeDimensions[id].bodyHeight = target.offsetHeight; - } + let nodeDimensions: Map | undefined; + for (const [nodeId, dimensions] of updates) { + const previous = state.nodeDimensions.get(nodeId); + if (previous?.width === dimensions.width && previous.height === dimensions.height) continue; + nodeDimensions ??= new Map(state.nodeDimensions); + nodeDimensions.set(nodeId, dimensions); } + return nodeDimensions === undefined ? state : {nodeDimensions}; }), })), - ), -); + ); +} + +export const GraphRenderingStoreContext = createContext(null); + +export function useGraphRenderingStore(selector: (state: GraphRenderingState) => T): T { + const store = useContext(GraphRenderingStoreContext); + assertNotNull(store); + return useStore(store, selector); +} diff --git a/query-graphs/src/ui/tree-layout.ts b/query-graphs/src/ui/tree-layout.ts index ecff5de2..41151636 100644 --- a/query-graphs/src/ui/tree-layout.ts +++ b/query-graphs/src/ui/tree-layout.ts @@ -1,10 +1,9 @@ import * as d3flextree from "d3-flextree"; import * as d3hierarchy from "d3-hierarchy"; -import type {NodeDimensions} from "./store"; import type * as treeDescription from "../tree-description"; import type {TreeNode, TreeDescription} from "../tree-description"; -import type {Edge} from "@xyflow/react"; +import type {Dimensions, Edge} from "@xyflow/react"; import type {QueryGraphNode} from "./QueryNode"; import type {ColoredGraphEdge} from "./ColoredEdge"; import {assertNotNull} from "../assert"; @@ -25,10 +24,8 @@ interface TreeLayout { export function layoutTree( treeData: TreeDescription, nodeIds: Map, - nodeDimensions: Record, - expandedNodes: Record, + nodeDimensions: Map, expandedSubtrees: Record, - resizeObserver: ResizeObserver, ): TreeLayout { const root = d3hierarchy.hierarchy(treeData.root, (d) => { if (expandedSubtrees[nodeIds.get(d)!] && d.collapsedChildren) { @@ -38,28 +35,19 @@ export function layoutTree( }); // Layout the tree - const heighOffset = 60; + const heightOffset = 60; const treelayout = d3flextree .flextree() .nodeSize((d) => { const id = nodeIds.get(d.data); assertNotNull(id); - const dim = nodeDimensions[id]; - if ( - dim == undefined || - dim.headWidth === undefined || - dim.headHeight === undefined || - dim.bodyWidth === undefined || - dim.bodyHeight === undefined - ) { - // Layout is a two-pass process: node sizes are only known after they are rendered and - // measured by the ResizeObserver. On the first pass we lay out with this placeholder size, - // then re-render once the measured dimensions arrive in `nodeDimensions`. + const dim = nodeDimensions.get(id); + if (dim === undefined) { + // React Flow measures new nodes after their first render. It keeps them hidden until then, + // so this placeholder only determines where that measurement render happens. return [50, 50]; } - if (expandedNodes[id]) { - return [Math.max(dim.headWidth, dim.bodyWidth) + 20, dim.headHeight + dim.bodyHeight + heighOffset]; - } else return [dim.headWidth + 20, dim.headHeight + heighOffset]; + return [dim.width + 20, dim.height + heightOffset]; }) .spacing((a, b) => (a.parent === b.parent ? 10 : 40)); const layout = treelayout(root); @@ -74,7 +62,8 @@ export function layoutTree( id, position: {x: n.x, y: n.y}, type: "querynode", - data: {...n.data, resizeObserver}, + data: n.data, + measured: nodeDimensions.get(id), }; }); const edges: QueryGraphEdge[] = d3edges.map((e) => {