Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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<string, unknown>)?.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<string, unknown>)?.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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
});
});
Original file line number Diff line number Diff line change
@@ -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<string>(["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<E extends Edge>(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<string, unknown> = { ...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<E extends Edge>(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;
}
Loading