Browse Source

fix: stabilize array node updates and address CodeRabbit feedback

handoff-20260429-1057
Marquis Boyd 5 months ago
parent
commit
500b5b16cd
  1. 2
      src/components/__tests__/ConnectionDropMenu.test.tsx
  2. 21
      src/components/nodes/ArrayNode.tsx
  3. 132
      src/store/workflowStore.ts
  4. 12
      src/utils/__tests__/arrayParser.test.ts
  5. 8
      src/utils/arrayParser.ts

2
src/components/__tests__/ConnectionDropMenu.test.tsx

@ -245,7 +245,7 @@ describe("ConnectionDropMenu", () => {
it("should wrap around when navigating past last item", () => {
render(<ConnectionDropMenu {...defaultProps} handleType="text" connectionType="source" />);
// Text target options: Prompt, Prompt Constructor, Array, nanoBanana, generateVideo, generateAudio, llmGenerate (7 items)
// Text target labels: Prompt, Prompt Constructor, Array, Generate Image, Generate Video, Generate Audio, LLM Generate (7 items)
// Navigate down 7 times to wrap to first
fireEvent.keyDown(document, { key: "ArrowDown" });
fireEvent.keyDown(document, { key: "ArrowDown" });

21
src/components/nodes/ArrayNode.tsx

@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Handle, Node, NodeProps, Position, useReactFlow } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { useCommentNavigation } from "@/hooks/useCommentNavigation";
@ -28,6 +28,8 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
const getConnectedInputs = useWorkflowStore((state) => state.getConnectedInputs);
const edges = useWorkflowStore((state) => state.edges);
const { setNodes } = useReactFlow();
const lastSyncedInputRef = useRef<string | null>(null);
const lastDerivedWriteRef = useRef<string | null>(null);
const hasIncomingTextConnection = useMemo(() => {
return edges.some((edge) => {
@ -41,7 +43,12 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
useEffect(() => {
if (!hasIncomingTextConnection) return;
const { text } = getConnectedInputs(id);
if (text !== null && text !== nodeData.inputText) {
if (
text !== null &&
text !== nodeData.inputText &&
text !== lastSyncedInputRef.current
) {
lastSyncedInputRef.current = text;
updateNodeData(id, { inputText: text });
}
}, [hasIncomingTextConnection, getConnectedInputs, id, nodeData.inputText, updateNodeData]);
@ -66,6 +73,16 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
// Keep derived outputs in node data so execution/edges always read the latest values.
useEffect(() => {
const nextOutputText = JSON.stringify(parsed.items);
const writeSignature = `${parsed.error ?? ""}::${nextOutputText}`;
const needsSync =
parsed.error !== nodeData.error ||
nextOutputText !== (nodeData.outputText ?? "[]") ||
!arraysEqual(parsed.items, nodeData.outputItems || []);
if (!needsSync) return;
if (lastDerivedWriteRef.current === writeSignature) return;
lastDerivedWriteRef.current = writeSignature;
if (
parsed.error !== nodeData.error ||
nextOutputText !== (nodeData.outputText ?? "[]") ||

132
src/store/workflowStore.ts

@ -98,6 +98,57 @@ function saveLogSession(): void {
export type EdgeStyle = "angular" | "curved";
function buildConnectionEdgeData(
connection: Connection,
nodes: WorkflowNode[],
edges: WorkflowEdge[]
): Record<string, unknown> {
const baseData: Record<string, unknown> = { createdAt: Date.now() };
const sourceNode = nodes.find((n) => n.id === connection.source);
// Array node uses a single output handle; assign each edge a stable item index.
if (sourceNode?.type === "array" && (connection.sourceHandle || "text") === "text") {
const sourceData = sourceNode.data as Record<string, unknown>;
const selectedIndex = sourceData.selectedOutputIndex;
const outputItems = Array.isArray(sourceData.outputItems) ? sourceData.outputItems : [];
const outputCount = outputItems.length;
if (
typeof selectedIndex === "number" &&
Number.isInteger(selectedIndex) &&
selectedIndex >= 0 &&
(outputCount === 0 || selectedIndex < outputCount)
) {
baseData.arrayItemIndex = selectedIndex;
return baseData;
}
if (outputCount > 0) {
const existingArrayEdges = edges.filter(
(e) => e.source === connection.source && (e.sourceHandle || "text") === "text"
);
const lastEdge = existingArrayEdges.reduce<WorkflowEdge | null>((latest, edge) => {
if (!latest) return edge;
const latestTime = (latest.data as Record<string, unknown> | undefined)?.createdAt;
const edgeTime = (edge.data as Record<string, unknown> | undefined)?.createdAt;
return (typeof edgeTime === "number" && typeof latestTime === "number" && edgeTime > latestTime) ? edge : latest;
}, null);
const lastIndex = (lastEdge?.data as Record<string, unknown> | undefined)?.arrayItemIndex;
const startIndex = typeof lastIndex === "number" && Number.isInteger(lastIndex) && lastIndex >= 0
? lastIndex + 1
: existingArrayEdges.length;
baseData.arrayItemIndex = startIndex % outputCount;
} else {
baseData.arrayItemIndex = 0;
}
}
return baseData;
}
// Workflow file format
export interface WorkflowFile {
version: 1;
@ -484,46 +535,7 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
set((state) => ({
edges: addEdge(
(() => {
const sourceNode = state.nodes.find((n) => n.id === connection.source);
const baseData: Record<string, unknown> = { createdAt: Date.now() };
// Array node uses a single output handle; assign each edge a stable item index.
if (sourceNode?.type === "array" && (connection.sourceHandle || "text") === "text") {
const sourceData = sourceNode.data as Record<string, unknown>;
const selectedIndex = sourceData.selectedOutputIndex;
const outputItems = Array.isArray(sourceData.outputItems) ? sourceData.outputItems : [];
const outputCount = outputItems.length;
if (
typeof selectedIndex === "number" &&
Number.isInteger(selectedIndex) &&
selectedIndex >= 0 &&
(outputCount === 0 || selectedIndex < outputCount)
) {
baseData.arrayItemIndex = selectedIndex;
} else if (outputCount > 0) {
const existingArrayEdges = state.edges.filter(
(e) => e.source === connection.source && (e.sourceHandle || "text") === "text"
);
const lastEdge = existingArrayEdges.reduce<typeof existingArrayEdges[number] | null>((latest, edge) => {
if (!latest) return edge;
const latestTime = (latest.data as Record<string, unknown> | undefined)?.createdAt;
const edgeTime = (edge.data as Record<string, unknown> | undefined)?.createdAt;
return (typeof edgeTime === "number" && typeof latestTime === "number" && edgeTime > latestTime) ? edge : latest;
}, null);
const lastIndex = (lastEdge?.data as Record<string, unknown> | undefined)?.arrayItemIndex;
const startIndex = typeof lastIndex === "number" && Number.isInteger(lastIndex) && lastIndex >= 0
? lastIndex + 1
: existingArrayEdges.length;
baseData.arrayItemIndex = startIndex % outputCount;
} else {
baseData.arrayItemIndex = 0;
}
}
const baseData = buildConnectionEdgeData(connection, state.nodes, state.edges);
return {
...connection,
id: `edge-${connection.source}-${connection.target}-${connection.sourceHandle || "default"}-${connection.targetHandle || "default"}`,
@ -541,45 +553,7 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
set((state) => ({
edges: addEdge(
(() => {
const sourceNode = state.nodes.find((n) => n.id === connection.source);
const baseData: Record<string, unknown> = { createdAt: Date.now() };
if (sourceNode?.type === "array" && (connection.sourceHandle || "text") === "text") {
const sourceData = sourceNode.data as Record<string, unknown>;
const selectedIndex = sourceData.selectedOutputIndex;
const outputItems = Array.isArray(sourceData.outputItems) ? sourceData.outputItems : [];
const outputCount = outputItems.length;
if (
typeof selectedIndex === "number" &&
Number.isInteger(selectedIndex) &&
selectedIndex >= 0 &&
(outputCount === 0 || selectedIndex < outputCount)
) {
baseData.arrayItemIndex = selectedIndex;
} else if (outputCount > 0) {
const existingArrayEdges = state.edges.filter(
(e) => e.source === connection.source && (e.sourceHandle || "text") === "text"
);
const lastEdge = existingArrayEdges.reduce<typeof existingArrayEdges[number] | null>((latest, edge) => {
if (!latest) return edge;
const latestTime = (latest.data as Record<string, unknown> | undefined)?.createdAt;
const edgeTime = (edge.data as Record<string, unknown> | undefined)?.createdAt;
return (typeof edgeTime === "number" && typeof latestTime === "number" && edgeTime > latestTime) ? edge : latest;
}, null);
const lastIndex = (lastEdge?.data as Record<string, unknown> | undefined)?.arrayItemIndex;
const startIndex = typeof lastIndex === "number" && Number.isInteger(lastIndex) && lastIndex >= 0
? lastIndex + 1
: existingArrayEdges.length;
baseData.arrayItemIndex = startIndex % outputCount;
} else {
baseData.arrayItemIndex = 0;
}
}
const baseData = buildConnectionEdgeData(connection, state.nodes, state.edges);
return {
...connection,
id: `edge-${connection.source}-${connection.target}-${connection.sourceHandle || "default"}-${connection.targetHandle || "default"}`,

12
src/utils/__tests__/arrayParser.test.ts

@ -64,5 +64,17 @@ describe("parseTextToArray", () => {
expect(result.items).toEqual([]);
expect(result.error).toBeTruthy();
});
it("returns error when regex pattern is too long", () => {
const result = parseTextToArray("a,b,c", {
splitMode: "regex",
delimiter: "*",
regexPattern: "a".repeat(101),
trimItems: true,
removeEmpty: true,
});
expect(result.items).toEqual([]);
expect(result.error).toContain("Regex pattern too long");
});
});

8
src/utils/arrayParser.ts

@ -13,6 +13,8 @@ export interface ParseArrayResult {
error: string | null;
}
const MAX_REGEX_PATTERN_LENGTH = 100;
function parseRegexPattern(pattern: string): RegExp {
// Supports `/pattern/flags` and plain `pattern`.
const slashFormat = pattern.match(/^\/(.+)\/([a-z]*)$/i);
@ -39,6 +41,11 @@ export function parseTextToArray(
} else if (options.splitMode === "regex") {
if (!options.regexPattern) {
rawItems = [source];
} else if (options.regexPattern.length > MAX_REGEX_PATTERN_LENGTH) {
return {
items: [],
error: `Regex pattern too long (max ${MAX_REGEX_PATTERN_LENGTH} characters)`,
};
} else {
rawItems = source.split(parseRegexPattern(options.regexPattern));
}
@ -67,4 +74,3 @@ export function parseTextToArray(
return { items, error: null };
}

Loading…
Cancel
Save