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 };
}
-