diff --git a/src/components/__tests__/ConnectionDropMenu.test.tsx b/src/components/__tests__/ConnectionDropMenu.test.tsx index d4ba843d..9f79484e 100644 --- a/src/components/__tests__/ConnectionDropMenu.test.tsx +++ b/src/components/__tests__/ConnectionDropMenu.test.tsx @@ -245,7 +245,7 @@ describe("ConnectionDropMenu", () => { it("should wrap around when navigating past last item", () => { render(); - // 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" }); diff --git a/src/components/nodes/ArrayNode.tsx b/src/components/nodes/ArrayNode.tsx index 7348fbf3..952ac150 100644 --- a/src/components/nodes/ArrayNode.tsx +++ b/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) { const getConnectedInputs = useWorkflowStore((state) => state.getConnectedInputs); const edges = useWorkflowStore((state) => state.edges); const { setNodes } = useReactFlow(); + const lastSyncedInputRef = useRef(null); + const lastDerivedWriteRef = useRef(null); const hasIncomingTextConnection = useMemo(() => { return edges.some((edge) => { @@ -41,7 +43,12 @@ export function ArrayNode({ id, data, selected }: NodeProps) { 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) { // 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 ?? "[]") || diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index c35bb2b1..e0f846e7 100644 --- a/src/store/workflowStore.ts +++ b/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 { + const baseData: Record = { 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; + 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((latest, edge) => { + if (!latest) return edge; + const latestTime = (latest.data as Record | undefined)?.createdAt; + const edgeTime = (edge.data as Record | undefined)?.createdAt; + return (typeof edgeTime === "number" && typeof latestTime === "number" && edgeTime > latestTime) ? edge : latest; + }, null); + + const lastIndex = (lastEdge?.data as Record | 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((set, get) => ({ set((state) => ({ edges: addEdge( (() => { - const sourceNode = state.nodes.find((n) => n.id === connection.source); - const baseData: Record = { 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; - 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((latest, edge) => { - if (!latest) return edge; - const latestTime = (latest.data as Record | undefined)?.createdAt; - const edgeTime = (edge.data as Record | undefined)?.createdAt; - return (typeof edgeTime === "number" && typeof latestTime === "number" && edgeTime > latestTime) ? edge : latest; - }, null); - - const lastIndex = (lastEdge?.data as Record | 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((set, get) => ({ set((state) => ({ edges: addEdge( (() => { - const sourceNode = state.nodes.find((n) => n.id === connection.source); - const baseData: Record = { createdAt: Date.now() }; - - if (sourceNode?.type === "array" && (connection.sourceHandle || "text") === "text") { - const sourceData = sourceNode.data as Record; - 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((latest, edge) => { - if (!latest) return edge; - const latestTime = (latest.data as Record | undefined)?.createdAt; - const edgeTime = (edge.data as Record | undefined)?.createdAt; - return (typeof edgeTime === "number" && typeof latestTime === "number" && edgeTime > latestTime) ? edge : latest; - }, null); - - const lastIndex = (lastEdge?.data as Record | 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"}`, diff --git a/src/utils/__tests__/arrayParser.test.ts b/src/utils/__tests__/arrayParser.test.ts index 68288315..69f1a6fe 100644 --- a/src/utils/__tests__/arrayParser.test.ts +++ b/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"); + }); +}); diff --git a/src/utils/arrayParser.ts b/src/utils/arrayParser.ts index 1744f303..4c297e71 100644 --- a/src/utils/arrayParser.ts +++ b/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 }; } -