Spaces:
Runtime error
Runtime error
| "use client"; | |
| import { useCallback, useEffect, useRef, type ReactNode } from "react"; | |
| import { | |
| ReactFlow, | |
| Controls, | |
| type Node, | |
| type Edge, | |
| type NodeTypes, | |
| type EdgeTypes, | |
| type NodeMouseHandler, | |
| type ReactFlowInstance, | |
| } from "@xyflow/react"; | |
| import "@xyflow/react/dist/style.css"; | |
| const FIT_VIEW_OPTIONS = { padding: 0.22, duration: 250 } as const; | |
| const MIN_ZOOM = 0.08; | |
| const MAX_ZOOM = 2; | |
| const REFIT_DELAY_MS = 60; | |
| type FlowCanvasProps = { | |
| nodes: Node[]; | |
| edges: Edge[]; | |
| nodeTypes?: NodeTypes; | |
| edgeTypes?: EdgeTypes; | |
| /** | |
| * Changing this remounts the graph (fresh `fitView`). Pass a key derived from | |
| * the data identity (e.g. the sorted provider list) — same role as the | |
| * `key={providersKey}` ProviderTopology used. | |
| */ | |
| fitKey?: string | number; | |
| /** When false (default) the graph is read-only: no drag, no selection. */ | |
| interactive?: boolean; | |
| /** Sizing/theme classes for the container that hosts the canvas. */ | |
| className?: string; | |
| onNodeClick?: NodeMouseHandler; | |
| /** Overlays rendered inside the canvas (after Controls). */ | |
| children?: ReactNode; | |
| }; | |
| /** | |
| * Reusable ReactFlow wrapper (U0), extracted from `ProviderTopology` without | |
| * behavioural change: auto-fit on init, on resize (ResizeObserver) and on node | |
| * count change; attribution hidden; read-only by default. Shared by the home | |
| * topology, the Combo/Routing Studio (Tela B) and the Compression Studio (Tela A). | |
| * | |
| * ## Stability fix (Bug #4 — plans/2026-06-23-omniroute-v3.8.34-deep-audit.md) | |
| * | |
| * Earlier revisions captured the ReactFlow instance in a plain `useRef` that | |
| * outlived remounts. When the parent re-rendered with a new `fitKey`, the | |
| * `<ReactFlow key={fitKey} ...>` remounted the graph (a brand-new instance), | |
| * but the `useEffect`s at the bottom of this file could still call | |
| * `.fitView()` on the *stale* ref. React Flow walks its internal | |
| * `nodeLookup` map and throws "Node cannot be found in the current page" | |
| * when the lookup is in a transient state during a remount. The fix is a | |
| * generation counter: every `onInit` bumps a counter, and every queued | |
| * `fitView` call checks the counter before touching the instance. | |
| */ | |
| export function FlowCanvas({ | |
| nodes, | |
| edges, | |
| nodeTypes, | |
| edgeTypes, | |
| fitKey, | |
| interactive = false, | |
| className = "h-full w-full min-w-0 overflow-hidden", | |
| onNodeClick, | |
| children, | |
| }: FlowCanvasProps) { | |
| const rfInstance = useRef<ReactFlowInstance | null>(null); | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| // Bumped on every onInit so queued fitView calls can be invalidated when | |
| // a new ReactFlow instance mounts (e.g. via fitKey change). | |
| const generationRef = useRef(0); | |
| const onInit = useCallback((instance: ReactFlowInstance) => { | |
| const generation = ++generationRef.current; | |
| rfInstance.current = instance; | |
| // Defer fitView until ReactFlow has measured its viewport, but guard | |
| // against the instance being replaced (generation mismatch) before the | |
| // timer fires — see Bug #4 in the audit report. | |
| setTimeout(() => { | |
| if (generationRef.current === generation) { | |
| instance.fitView(FIT_VIEW_OPTIONS); | |
| } | |
| }, REFIT_DELAY_MS); | |
| }, []); | |
| useEffect(() => { | |
| const el = containerRef.current; | |
| if (!el) return; | |
| const ro = new ResizeObserver(() => { | |
| rfInstance.current?.fitView(FIT_VIEW_OPTIONS); | |
| }); | |
| ro.observe(el); | |
| return () => ro.disconnect(); | |
| }, []); | |
| useEffect(() => { | |
| // Snapshot the generation so a queued callback that fires after a | |
| // remount is silently dropped (no fitView on stale instance). | |
| const generation = generationRef.current; | |
| const id = setTimeout(() => { | |
| if (generationRef.current === generation) { | |
| rfInstance.current?.fitView(FIT_VIEW_OPTIONS); | |
| } | |
| }, REFIT_DELAY_MS); | |
| return () => clearTimeout(id); | |
| }, [nodes.length]); | |
| // Clear the ref on unmount so a late-arriving callback (e.g. a ResizeObserver | |
| // tick fired just before the React tree unmounted) cannot reach into a | |
| // disposed ReactFlow instance. | |
| useEffect(() => { | |
| return () => { | |
| rfInstance.current = null; | |
| }; | |
| }, []); | |
| return ( | |
| <div ref={containerRef} className={className}> | |
| <ReactFlow | |
| key={fitKey} | |
| nodes={nodes} | |
| edges={edges} | |
| nodeTypes={nodeTypes} | |
| edgeTypes={edgeTypes} | |
| onNodeClick={onNodeClick} | |
| fitView | |
| fitViewOptions={FIT_VIEW_OPTIONS} | |
| minZoom={MIN_ZOOM} | |
| maxZoom={MAX_ZOOM} | |
| onInit={onInit} | |
| proOptions={{ hideAttribution: true }} | |
| panOnDrag | |
| zoomOnScroll | |
| zoomOnPinch | |
| zoomOnDoubleClick | |
| preventScrolling={false} | |
| nodesDraggable={interactive} | |
| nodesConnectable={false} | |
| elementsSelectable={interactive} | |
| > | |
| <Controls showInteractive={false} /> | |
| {children} | |
| </ReactFlow> | |
| </div> | |
| ); | |
| } | |
| export default FlowCanvas; | |