diff --git a/understand-anything-plugin/packages/dashboard/src/components/DomainGraphView.tsx b/understand-anything-plugin/packages/dashboard/src/components/DomainGraphView.tsx index ff158467..60d0225a 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/DomainGraphView.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/DomainGraphView.tsx @@ -20,6 +20,7 @@ import { useDashboardStore } from "../store"; import { useI18n } from "../contexts/I18nContext"; import { mergeElkPositions, nodesToElkInput } from "../utils/layout"; import { applyElkLayout } from "../utils/elk-layout"; +import { sanitizeFlowEdges } from "../utils/sanitizeFlowEdge"; import type { KnowledgeGraph, GraphNode } from "@understand-anything/core/types"; const nodeTypes = { @@ -206,7 +207,10 @@ function DomainGraphViewInner() { } setLayout({ nodes: mergeElkPositions(nodesArray, positioned), - edges: edgesArray, + // Strip null / "null" / undefined sourceHandle/targetHandle so + // React Flow falls back to the node's default handle instead of + // looping on an unresolvable handle id (issue #330). + edges: sanitizeFlowEdges(edgesArray), }); }) .catch((err) => { diff --git a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx index 2a5037a1..8d53d617 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx @@ -53,6 +53,7 @@ import { import { deriveContainers } from "../utils/containers"; import type { DerivedContainer } from "../utils/containers"; import { computeLayerStats } from "../utils/layerStats"; +import { sanitizeFlowEdges } from "../utils/sanitizeFlowEdge"; const nodeTypes = { custom: CustomNode, @@ -312,7 +313,10 @@ function useOverviewGraph() { useDashboardStore.getState().appendLayoutIssues(issues); } const positionedNodes = mergeElkPositions(baseNodes, positioned); - setOverview({ nodes: positionedNodes, edges: flowEdges }); + // Strip null / "null" / undefined sourceHandle/targetHandle so React + // Flow falls back to the node's default handle instead of looping on + // an unresolvable handle id (issue #330). + setOverview({ nodes: positionedNodes, edges: sanitizeFlowEdges(flowEdges) }); setLayoutStatus("ready"); }) .catch((err) => { @@ -1262,20 +1266,34 @@ function useLayerDetailGraph() { // Compose: Stage 1 / inflated edges, plus portal edges (Stage 1 sources // them off container atoms — re-sourcing on expand is deferred). const base = [...expandedEdges, ...topo.portalEdges]; - if (!selectedNodeId) return base; - - // Apply selection-based edge styling on top of topology edges - return base.map((edge) => { - const isSelectedEdge = edge.source === selectedNodeId || edge.target === selectedNodeId; - // Don't restyle diff-impacted or portal edges - if ((edge.style as Record)?.strokeDasharray) return edge; - - if (isSelectedEdge) { - return { ...edge, animated: true, style: { stroke: "rgba(212,165,116,0.8)", strokeWidth: 2.5 }, labelStyle: { fill: "#d4a574", fontSize: 11, fontWeight: 600 } }; - } - // Fade unrelated edges - return { ...edge, animated: false, style: { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 }, labelStyle: { fill: "rgba(163,151,135,0.2)", fontSize: 10 } }; - }); + const styled = !selectedNodeId + ? base + : base.map((edge) => { + const isSelectedEdge = + edge.source === selectedNodeId || edge.target === selectedNodeId; + // Don't restyle diff-impacted or portal edges + if ((edge.style as Record)?.strokeDasharray) return edge; + + if (isSelectedEdge) { + return { + ...edge, + animated: true, + style: { stroke: "rgba(212,165,116,0.8)", strokeWidth: 2.5 }, + labelStyle: { fill: "#d4a574", fontSize: 11, fontWeight: 600 }, + }; + } + // Fade unrelated edges + return { + ...edge, + animated: false, + style: { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 }, + labelStyle: { fill: "rgba(163,151,135,0.2)", fontSize: 10 }, + }; + }); + // Strip null / "null" / undefined sourceHandle/targetHandle so React + // Flow falls back to the node's default handle instead of looping on + // an unresolvable handle id (issue #330). + return sanitizeFlowEdges(styled); }, [expandedEdges, topo.portalEdges, selectedNodeId]); // Expose container topology so the parent component can wire auto-expand diff --git a/understand-anything-plugin/packages/dashboard/src/components/KnowledgeGraphView.tsx b/understand-anything-plugin/packages/dashboard/src/components/KnowledgeGraphView.tsx index f53e5778..8b50dab4 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/KnowledgeGraphView.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/KnowledgeGraphView.tsx @@ -14,6 +14,7 @@ import CustomNode from "./CustomNode"; import type { CustomNodeData } from "./CustomNode"; import { useDashboardStore } from "../store"; import { applyForceLayout, NODE_WIDTH, NODE_HEIGHT } from "../utils/layout"; +import { sanitizeFlowEdges } from "../utils/sanitizeFlowEdge"; import type { KnowledgeGraph } from "@understand-anything/core/types"; const nodeTypes = { @@ -232,7 +233,10 @@ function KnowledgeGraphViewInner() { }; }); - return { nodes: rfNodes, edges: rfEdges }; + // Strip null / "null" / undefined sourceHandle/targetHandle so React + // Flow falls back to the node's default handle instead of looping on + // an unresolvable handle id (issue #330). + return { nodes: rfNodes, edges: sanitizeFlowEdges(rfEdges) }; }, [filteredGraph, selectedNodeId, focusNodeId, searchResults, tourSet, onNodeClick, positionMap, edgeCounts]); if (!graph) { diff --git a/understand-anything-plugin/packages/dashboard/src/utils/__tests__/sanitizeFlowEdge.test.ts b/understand-anything-plugin/packages/dashboard/src/utils/__tests__/sanitizeFlowEdge.test.ts new file mode 100644 index 00000000..2808c844 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/utils/__tests__/sanitizeFlowEdge.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest"; +import type { Edge } from "@xyflow/react"; +import { + isInvalidHandleValue, + sanitizeFlowEdge, + sanitizeFlowEdges, +} from "../sanitizeFlowEdge"; + +describe("isInvalidHandleValue", () => { + it.each([ + ["null literal", null], + ["undefined", undefined], + ['string "null"', "null"], + ['string "undefined"', "undefined"], + ["empty string", ""], + ])("flags %s as invalid", (_label, value) => { + expect(isInvalidHandleValue(value)).toBe(true); + }); + + it.each([ + ["a real handle id", "top"], + ["a numeric-ish handle id", "0"], + ["a number", 1], + ])("keeps %s as valid", (_label, value) => { + expect(isInvalidHandleValue(value)).toBe(false); + }); +}); + +describe("sanitizeFlowEdge", () => { + const base: Edge = { id: "e-35", source: "n1", target: "n2" }; + + it("returns the original edge when no handle fields are present", () => { + const result = sanitizeFlowEdge(base); + expect(result).toBe(base); + }); + + it('strips a sourceHandle set to the literal string "null"', () => { + // Matches the exact scenario in issue #330 where React Flow logs + // `Couldn't create edge for source handle id: "null", edge id: e-35.` + // and then hangs trying to resolve the missing handle. + const edge: Edge = { ...base, sourceHandle: "null" }; + const result = sanitizeFlowEdge(edge); + expect(result).not.toBe(edge); + expect("sourceHandle" in result).toBe(false); + expect(result.id).toBe("e-35"); + expect(result.source).toBe("n1"); + expect(result.target).toBe("n2"); + }); + + it("strips a sourceHandle set to the JS null value", () => { + const edge = { ...base, sourceHandle: null } as unknown as Edge; + const result = sanitizeFlowEdge(edge); + expect("sourceHandle" in result).toBe(false); + }); + + it('strips a targetHandle set to "undefined"', () => { + const edge: Edge = { ...base, targetHandle: "undefined" }; + const result = sanitizeFlowEdge(edge); + expect("targetHandle" in result).toBe(false); + }); + + it("strips both source and target handles when both are invalid", () => { + const edge: Edge = { + ...base, + sourceHandle: "null", + targetHandle: null as unknown as string, + }; + const result = sanitizeFlowEdge(edge); + expect("sourceHandle" in result).toBe(false); + expect("targetHandle" in result).toBe(false); + }); + + it("preserves a real sourceHandle id", () => { + const edge: Edge = { ...base, sourceHandle: "right" }; + const result = sanitizeFlowEdge(edge); + expect(result).toBe(edge); + expect(result.sourceHandle).toBe("right"); + }); + + it("strips an empty string sourceHandle", () => { + // Empty string is treated as "no handle" so it should be stripped — + // documents the chosen normalisation rule. + const edge: Edge = { ...base, sourceHandle: "" }; + const result = sanitizeFlowEdge(edge); + expect("sourceHandle" in result).toBe(false); + }); + + it("preserves other edge fields (style, label, animated)", () => { + const edge: Edge = { + ...base, + sourceHandle: "null", + label: "calls", + animated: true, + style: { stroke: "red" }, + }; + const result = sanitizeFlowEdge(edge); + expect("sourceHandle" in result).toBe(false); + expect(result.label).toBe("calls"); + expect(result.animated).toBe(true); + expect(result.style).toEqual({ stroke: "red" }); + }); +}); + +describe("sanitizeFlowEdges", () => { + it("returns the original array reference when nothing needs cleaning", () => { + const edges: Edge[] = [ + { id: "a", source: "1", target: "2" }, + { id: "b", source: "2", target: "3", sourceHandle: "right" }, + ]; + expect(sanitizeFlowEdges(edges)).toBe(edges); + }); + + it("returns a new array when any edge needed cleaning, leaving clean edges by reference", () => { + const clean: Edge = { id: "a", source: "1", target: "2" }; + const dirty: Edge = { id: "b", source: "2", target: "3", sourceHandle: "null" }; + const input = [clean, dirty]; + const result = sanitizeFlowEdges(input); + // New array because at least one edge was cleaned. + expect(result).not.toBe(input); + // Clean edges keep their original reference for memo stability. + expect(result[0]).toBe(clean); + // Dirty edge has the bogus handle field stripped. + expect("sourceHandle" in result[1]).toBe(false); + expect(result[1].id).toBe("b"); + }); +}); diff --git a/understand-anything-plugin/packages/dashboard/src/utils/sanitizeFlowEdge.ts b/understand-anything-plugin/packages/dashboard/src/utils/sanitizeFlowEdge.ts new file mode 100644 index 00000000..6c1a3a0b --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/utils/sanitizeFlowEdge.ts @@ -0,0 +1,64 @@ +import type { Edge } from "@xyflow/react"; + +/** + * Values we treat as "no handle specified" so React Flow falls back to the + * node's default handle / center. + * + * - `null` / `undefined`: missing field, often introduced by upstream loaders + * that round-trip an edge object and reset unset handles to `null`. + * - `"null"` / `"undefined"`: the literal strings, which sneak in when + * an upstream tool stringifies a missing value (e.g. `String(null)`) or + * when a graph JSON has a `sourceHandle: "null"` field that survived + * schema stripping. See issue #330: when these reach React Flow it logs + * `Couldn't create edge for source handle id: "null"` and the rendering + * loop hangs trying to re-resolve the missing handle every frame. + * - `""`: empty string, treated the same as missing. + */ +const INVALID_HANDLE_VALUES = new Set(["null", "undefined", ""]); + +/** + * Return true when `value` should be considered "no handle id" and stripped + * from a React Flow edge before render. + */ +export function isInvalidHandleValue(value: unknown): boolean { + if (value === null || value === undefined) return true; + if (typeof value !== "string") return false; + return INVALID_HANDLE_VALUES.has(value); +} + +/** + * Normalise a React Flow edge so it never carries a bogus `sourceHandle` / + * `targetHandle`. When the field is present but holds `null`, `undefined`, + * `""`, `"null"`, or `"undefined"`, we delete the field entirely so React + * Flow's handle lookup picks the node's first (default) handle. This avoids + * the `error008` warning loop that freezes the dashboard when a user clicks + * a search result whose target layer renders edges that point at a node + * with no matching handle id (issue #330). + * + * The function returns the original edge reference when no change is needed + * so memoised React Flow renders still see referential equality. + */ +export function sanitizeFlowEdge(edge: E): E { + const hasInvalidSource = + "sourceHandle" in edge && isInvalidHandleValue(edge.sourceHandle); + const hasInvalidTarget = + "targetHandle" in edge && isInvalidHandleValue(edge.targetHandle); + if (!hasInvalidSource && !hasInvalidTarget) return edge; + + const cleaned: Record = { ...edge }; + if (hasInvalidSource) delete cleaned.sourceHandle; + if (hasInvalidTarget) delete cleaned.targetHandle; + return cleaned as E; +} + +/** Convenience wrapper for an array of edges. */ +export function sanitizeFlowEdges(edges: E[]): E[] { + let mutated = false; + const out: E[] = new Array(edges.length); + for (let i = 0; i < edges.length; i++) { + const next = sanitizeFlowEdge(edges[i]); + if (next !== edges[i]) mutated = true; + out[i] = next; + } + return mutated ? out : edges; +}