ai_api / src /shared /components /flow /FlowCanvas.tsx
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
4.94 kB
"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;