fix(core): Execute multi-input nodes re-fired through a loop-back edge in legacy order (#37561)

Co-authored-by: Claude Fable 5 <[email protected]>
This commit is contained in:
Mike Repeć
2026-09-03 07:48:00 +00:00
committed by GitHub
co-authored by Claude Fable 5
parent 7c21f4b47c
commit 903fe572af
3 changed files with 214 additions and 0 deletions
@@ -2248,6 +2248,62 @@ describe('WorkflowExecute', () => {
});
});
describe('prepareConnectionInputData', () => {
// Legacy (v0) order with the first input dead-padded: data delivered
// through a loop-back edge is used, data from upstream keeps the drop.
const workflow = new Workflow({
id: 'test',
nodes: [
{
parameters: {},
id: 'uuid-1',
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
},
{
parameters: {},
id: 'uuid-2',
name: 'Loop',
type: 'n8n-nodes-base.merge',
typeVersion: 2.1,
position: [200, 0],
},
],
connections: {
Start: { main: [[{ node: 'Loop', type: NodeConnectionTypes.Main, index: 0 }]] },
Loop: { main: [[{ node: 'Loop', type: NodeConnectionTypes.Main, index: 1 }]] },
},
active: false,
nodeTypes: Helpers.NodeTypes(),
settings: { executionOrder: 'v0' },
});
const node = workflow.getNode('Loop')!;
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
const workflowExecute = new WorkflowExecute(mock<IWorkflowExecuteAdditionalData>(), 'manual');
const loopBackItems = [{ json: { attempt: 1 } }];
const prepare = (previousNode: string) =>
// @ts-expect-error private method
workflowExecute.prepareConnectionInputData(
workflow,
nodeType,
undefined,
node,
{ main: [[], loopBackItems] },
{ main: [null, { previousNode }] },
);
test('should use data a node sent to a later input of itself', () => {
expect(prepare('Loop')).toEqual(loopBackItems);
});
test('should skip the run when the data came from an upstream node', () => {
expect(prepare('Start')).toBeNull();
});
});
describe('incomingConnectionIsEmpty', () => {
let workflowExecute: WorkflowExecute;
@@ -26,6 +26,7 @@ import type {
IRunData,
ITaskData,
ITaskDataConnections,
ITaskDataConnectionsSource,
ITaskMetadata,
NodeOperationError,
Workflow,
@@ -967,7 +968,9 @@ export class WorkflowExecute {
workflow: Workflow,
nodeType: INodeType,
customOperation: ReturnType<WorkflowExecute['getCustomOperation']>,
node: INode,
inputData: ITaskDataConnections,
source: ITaskDataConnectionsSource | null,
): INodeExecutionData[] | null {
if (
nodeType.execute ||
@@ -991,6 +994,26 @@ export class WorkflowExecute {
break;
}
}
} else if (!connectionInputData?.length && source?.main?.[0] === null) {
// The first input is empty and can no longer receive data. If another
// input got its data from a node further down the workflow (a loop
// back to this node), use that data so the loop keeps running. Data
// from anywhere else keeps the old behavior: skip this node run.
const descendants = workflow.getChildNodes(node.name);
for (let inputIndex = 0; inputIndex < inputData.main.length; inputIndex++) {
const mainData = inputData.main[inputIndex];
const previousNode = source.main[inputIndex]?.previousNode;
if (
mainData?.length &&
previousNode !== undefined &&
// getChildNodes never lists the node itself, so a direct
// self-edge counts as a loop back too
(previousNode === node.name || descendants.includes(previousNode))
) {
connectionInputData = mainData;
break;
}
}
}
if (!connectionInputData || connectionInputData.length === 0) {
@@ -1369,7 +1392,9 @@ export class WorkflowExecute {
workflow,
nodeType,
customOperation,
node,
inputData,
executionData.source,
);
if (connectionInputData === null) {
+133
View File
@@ -2824,6 +2824,139 @@ export const legacyWorkflowExecuteTests: WorkflowTestData[] = [
nodeData: {},
},
},
{
description:
'should keep executing when a loop-back edge lands on a later input of a multi-input node',
input: {
workflowData: {
nodes: [
{
parameters: {},
id: 'b8b0b1a0-0000-4000-8000-000000000001',
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
},
{
parameters: {},
id: 'b8b0b1a0-0000-4000-8000-000000000002',
name: 'Merge',
type: 'n8n-nodes-base.merge',
typeVersion: 2.1,
position: [200, 0],
},
{
parameters: {
conditions: {
number: [
{
value1: '={{ $json.attempt || 0 }}',
operation: 'larger',
value2: 0,
},
],
},
},
id: 'b8b0b1a0-0000-4000-8000-000000000003',
name: 'IF',
type: 'n8n-nodes-base.if',
typeVersion: 1,
position: [400, 0],
},
{
parameters: {
values: {
number: [
{
name: 'attempt',
value: '={{ ($input.first().json.attempt || 0) + 1 }}',
},
],
},
options: {},
},
id: 'b8b0b1a0-0000-4000-8000-000000000004',
name: 'Retry',
type: 'n8n-nodes-base.set',
typeVersion: 2,
position: [400, 200],
},
{
parameters: {},
id: 'b8b0b1a0-0000-4000-8000-000000000005',
name: 'Done',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [600, 0],
},
],
connections: {
Start: {
main: [
[
{
node: 'Merge',
type: NodeConnectionTypes.Main,
index: 0,
},
],
],
},
Merge: {
main: [
[
{
node: 'IF',
type: NodeConnectionTypes.Main,
index: 0,
},
],
],
},
IF: {
main: [
[
{
node: 'Done',
type: NodeConnectionTypes.Main,
index: 0,
},
],
[
{
node: 'Retry',
type: NodeConnectionTypes.Main,
index: 0,
},
],
],
},
Retry: {
main: [
[
{
node: 'Merge',
type: NodeConnectionTypes.Main,
index: 1,
},
],
],
},
},
},
},
output: {
nodeExecutionOrder: ['Start', 'Merge', 'IF', 'Retry', 'Merge', 'IF', 'Done'],
nodeData: {
Start: [[{}]],
Merge: [[{}], [{ attempt: 1 }]],
IF: [[], [{ attempt: 1 }]],
Retry: [[{ attempt: 1 }]],
Done: [[{ attempt: 1 }]],
},
},
},
];
export const v1WorkflowExecuteTests: WorkflowTestData[] = [