Spaces:
Runtime error
Runtime error
File size: 4,936 Bytes
cd8bd0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | "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;
|