From 974a19dc415efdb1181fa9a217afaea96d96c8cd Mon Sep 17 00:00:00 2001 From: Rob Hough Date: Tue, 24 Feb 2026 12:14:28 +0000 Subject: [PATCH 1/2] fix(editor): Switch to inner shadow for Button (no-changelog) (#26175) --- .../src/components/N8nButton/Button.vue | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.vue b/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.vue index b0862c1bab9..41c7bc745c6 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.vue @@ -160,7 +160,9 @@ const handleClick = (event: MouseEvent) => { background-color: var(--button--color--background); color: var(--button--color); - box-shadow: var(--button--shadow), var(--button--border--shadow); + box-shadow: + inset var(--button--border--shadow), + var(--button--shadow); border: none; > * { @@ -169,12 +171,16 @@ const handleClick = (event: MouseEvent) => { &:hover { background-color: var(--button--color--background-hover); - box-shadow: var(--button--shadow--hover), var(--button--border--shadow--hover); + box-shadow: + inset var(--button--border--shadow--hover), + var(--button--shadow--hover); } &:active { background-color: var(--button--color--background-active); - box-shadow: var(--button--shadow--active), var(--button--border--shadow--active); + box-shadow: + inset var(--button--border--shadow--active), + var(--button--shadow--active); } &:focus { From b585be51887f7d553686607af859fab04d0dcc0b Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:19:57 +0100 Subject: [PATCH 2/2] fix(core): Dispatch composite targets in onError() instead of dropping them (#25981) Co-authored-by: Claude Opus 4.6 --- .../src/codegen/edge-case-roundtrip.test.ts | 231 ++++++++++++++++++ .../workflow-sdk/src/workflow-builder.test.ts | 183 ++++++++++++++ .../@n8n/workflow-sdk/src/workflow-builder.ts | 21 +- 3 files changed, 431 insertions(+), 4 deletions(-) diff --git a/packages/@n8n/workflow-sdk/src/codegen/edge-case-roundtrip.test.ts b/packages/@n8n/workflow-sdk/src/codegen/edge-case-roundtrip.test.ts index bb18d4bb331..a5fff2acede 100644 --- a/packages/@n8n/workflow-sdk/src/codegen/edge-case-roundtrip.test.ts +++ b/packages/@n8n/workflow-sdk/src/codegen/edge-case-roundtrip.test.ts @@ -887,4 +887,235 @@ export default workflow('test', 'Test').add(t.to(n));`; expect(parsedJson.connections['Process']?.main[0]?.[0]?.node).toBe('Process 1'); }); }); + + describe('Edge Case: onError with IF composite', () => { + it('should roundtrip onError connected to IF with true/false branches', () => { + const originalJson: WorkflowJSON = { + id: 'onerror-if-test', + name: 'OnError IF Test', + nodes: [ + { + id: 'trigger-1', + name: 'Start', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: 'http-1', + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [200, 0], + parameters: { url: 'https://api.example.com' }, + onError: 'continueErrorOutput', + }, + { + id: 'if-1', + name: 'Error IF', + type: 'n8n-nodes-base.if', + typeVersion: 2, + position: [400, 100], + parameters: {}, + }, + { + id: 'true-1', + name: 'Retry', + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [600, 0], + parameters: {}, + }, + { + id: 'false-1', + name: 'Log Error', + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [600, 200], + parameters: {}, + }, + ], + connections: { + Start: { + main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]], + }, + 'HTTP Request': { + main: [ + [], // Output 0 - success (empty) + [{ node: 'Error IF', type: 'main', index: 0 }], // Output 1 - error + ], + }, + 'Error IF': { + main: [ + [{ node: 'Retry', type: 'main', index: 0 }], + [{ node: 'Log Error', type: 'main', index: 0 }], + ], + }, + }, + }; + + const code = generateWorkflowCode(originalJson); + const parsedJson = parseWorkflowCode(code); + + expect(parsedJson.nodes).toHaveLength(5); + expect(parsedJson.connections['HTTP Request']?.main[1]?.[0]?.node).toBe('Error IF'); + expect(parsedJson.connections['Error IF']?.main[0]?.[0]?.node).toBe('Retry'); + expect(parsedJson.connections['Error IF']?.main[1]?.[0]?.node).toBe('Log Error'); + }); + }); + + describe('Edge Case: onError with Switch composite', () => { + it('should roundtrip onError connected to Switch with case branches', () => { + const originalJson: WorkflowJSON = { + id: 'onerror-switch-test', + name: 'OnError Switch Test', + nodes: [ + { + id: 'trigger-1', + name: 'Start', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: 'http-1', + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [200, 0], + parameters: { url: 'https://api.example.com' }, + onError: 'continueErrorOutput', + }, + { + id: 'switch-1', + name: 'Error Router', + type: 'n8n-nodes-base.switch', + typeVersion: 3, + position: [400, 100], + parameters: { mode: 'rules' }, + }, + { + id: 'case-0', + name: 'Handle 404', + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [600, 0], + parameters: {}, + }, + { + id: 'case-1', + name: 'Handle 500', + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [600, 200], + parameters: {}, + }, + ], + connections: { + Start: { + main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]], + }, + 'HTTP Request': { + main: [ + [], // Output 0 - success (empty) + [{ node: 'Error Router', type: 'main', index: 0 }], // Output 1 - error + ], + }, + 'Error Router': { + main: [ + [{ node: 'Handle 404', type: 'main', index: 0 }], + [{ node: 'Handle 500', type: 'main', index: 0 }], + ], + }, + }, + }; + + const code = generateWorkflowCode(originalJson); + const parsedJson = parseWorkflowCode(code); + + expect(parsedJson.nodes).toHaveLength(5); + expect(parsedJson.connections['HTTP Request']?.main[1]?.[0]?.node).toBe('Error Router'); + expect(parsedJson.connections['Error Router']?.main[0]?.[0]?.node).toBe('Handle 404'); + expect(parsedJson.connections['Error Router']?.main[1]?.[0]?.node).toBe('Handle 500'); + }); + }); + + describe('Edge Case: onError with SplitInBatches composite', () => { + it('should roundtrip onError connected to SplitInBatches with done/each branches', () => { + const originalJson: WorkflowJSON = { + id: 'onerror-sib-test', + name: 'OnError SIB Test', + nodes: [ + { + id: 'trigger-1', + name: 'Start', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: 'http-1', + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [200, 0], + parameters: { url: 'https://api.example.com' }, + onError: 'continueErrorOutput', + }, + { + id: 'sib-1', + name: 'Error Batcher', + type: 'n8n-nodes-base.splitInBatches', + typeVersion: 3, + position: [400, 100], + parameters: { batchSize: 10 }, + }, + { + id: 'done-1', + name: 'All Done', + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [600, 0], + parameters: {}, + }, + { + id: 'each-1', + name: 'Process Each', + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [600, 200], + parameters: {}, + }, + ], + connections: { + Start: { + main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]], + }, + 'HTTP Request': { + main: [ + [], // Output 0 - success (empty) + [{ node: 'Error Batcher', type: 'main', index: 0 }], // Output 1 - error + ], + }, + 'Error Batcher': { + main: [ + [{ node: 'All Done', type: 'main', index: 0 }], // Output 0 - done + [{ node: 'Process Each', type: 'main', index: 0 }], // Output 1 - each + ], + }, + }, + }; + + const code = generateWorkflowCode(originalJson); + const parsedJson = parseWorkflowCode(code); + + expect(parsedJson.nodes).toHaveLength(5); + expect(parsedJson.connections['HTTP Request']?.main[1]?.[0]?.node).toBe('Error Batcher'); + expect(parsedJson.connections['Error Batcher']?.main[0]?.[0]?.node).toBe('All Done'); + expect(parsedJson.connections['Error Batcher']?.main[1]?.[0]?.node).toBe('Process Each'); + }); + }); }); diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder.test.ts b/packages/@n8n/workflow-sdk/src/workflow-builder.test.ts index fc979256c7c..03796565cb7 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder.test.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder.test.ts @@ -1,5 +1,6 @@ import type { NodeInstance, WorkflowJSON } from './types/base'; import { workflow } from './workflow-builder'; +import { splitInBatches } from './workflow-builder/control-flow-builders/split-in-batches'; import { node, trigger, sticky } from './workflow-builder/node-builders/node-builder'; import { languageModel, @@ -414,6 +415,188 @@ describe('Workflow Builder', () => { // Slack's error output (index 1) should connect to Telegram expect(json.connections['Send Slack']?.main[1]?.[0]?.node).toBe('Error Alert'); }); + + it('should handle IfElse composite as onError target on standalone node', () => { + const httpNode = node({ + type: 'n8n-nodes-base.httpRequest', + version: 4.2, + config: { name: 'HTTP', onError: 'continueErrorOutput' }, + }); + const ifNode = node({ + type: 'n8n-nodes-base.if', + version: 2, + config: { name: 'IF' }, + }) as NodeInstance<'n8n-nodes-base.if', string, unknown>; + const trueHandler = node({ + type: 'n8n-nodes-base.noOp', + version: 1, + config: { name: 'True Handler' }, + }); + const falseHandler = node({ + type: 'n8n-nodes-base.noOp', + version: 1, + config: { name: 'False Handler' }, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + httpNode.onError(ifNode.onTrue!(trueHandler).onFalse(falseHandler) as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wf = workflow('test-id', 'Test').add(httpNode as any); + const json = wf.toJSON(); + + expect(json.nodes).toHaveLength(4); + expect(json.connections['HTTP']?.main[1]?.[0]?.node).toBe('IF'); + expect(json.connections['IF']?.main[0]?.[0]?.node).toBe('True Handler'); + expect(json.connections['IF']?.main[1]?.[0]?.node).toBe('False Handler'); + }); + + it('should handle SwitchCase composite as onError target on standalone node', () => { + const httpNode = node({ + type: 'n8n-nodes-base.httpRequest', + version: 4.2, + config: { name: 'HTTP', onError: 'continueErrorOutput' }, + }); + const switchNode = node({ + type: 'n8n-nodes-base.switch', + version: 3.2, + config: { name: 'Switch', parameters: { mode: 'rules' } }, + }) as NodeInstance<'n8n-nodes-base.switch', string, unknown>; + const case0 = node({ + type: 'n8n-nodes-base.noOp', + version: 1, + config: { name: 'Case 0' }, + }); + const case1 = node({ + type: 'n8n-nodes-base.noOp', + version: 1, + config: { name: 'Case 1' }, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + httpNode.onError(switchNode.onCase!(0, case0).onCase(1, case1) as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wf = workflow('test-id', 'Test').add(httpNode as any); + const json = wf.toJSON(); + + expect(json.nodes).toHaveLength(4); + expect(json.connections['HTTP']?.main[1]?.[0]?.node).toBe('Switch'); + expect(json.connections['Switch']?.main[0]?.[0]?.node).toBe('Case 0'); + expect(json.connections['Switch']?.main[1]?.[0]?.node).toBe('Case 1'); + }); + + it('should handle SplitInBatches composite as onError target on standalone node', () => { + const httpNode = node({ + type: 'n8n-nodes-base.httpRequest', + version: 4.2, + config: { name: 'HTTP', onError: 'continueErrorOutput' }, + }); + const sibNode = node({ + type: 'n8n-nodes-base.splitInBatches', + version: 3, + config: { name: 'SIB', parameters: { batchSize: 10 } }, + }); + const doneNode = node({ + type: 'n8n-nodes-base.noOp', + version: 1, + config: { name: 'Done' }, + }); + const eachNode = node({ + type: 'n8n-nodes-base.noOp', + version: 1, + config: { name: 'Each' }, + }); + + const sibBuilder = splitInBatches(sibNode).onDone(doneNode).onEachBatch(eachNode); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + httpNode.onError(sibBuilder as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wf = workflow('test-id', 'Test').add(httpNode as any); + const json = wf.toJSON(); + + expect(json.nodes).toHaveLength(4); + expect(json.connections['HTTP']?.main[1]?.[0]?.node).toBe('SIB'); + expect(json.connections['SIB']?.main[0]?.[0]?.node).toBe('Done'); + expect(json.connections['SIB']?.main[1]?.[0]?.node).toBe('Each'); + }); + + it('should handle IfElse composite as onError target in chain', () => { + const t = trigger({ + type: 'n8n-nodes-base.manualTrigger', + version: 1, + config: { name: 'Start' }, + }); + const httpNode = node({ + type: 'n8n-nodes-base.httpRequest', + version: 4.2, + config: { name: 'HTTP', onError: 'continueErrorOutput' }, + }); + const ifNode = node({ + type: 'n8n-nodes-base.if', + version: 2, + config: { name: 'IF' }, + }) as NodeInstance<'n8n-nodes-base.if', string, unknown>; + const trueHandler = node({ + type: 'n8n-nodes-base.noOp', + version: 1, + config: { name: 'True Handler' }, + }); + const falseHandler = node({ + type: 'n8n-nodes-base.noOp', + version: 1, + config: { name: 'False Handler' }, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + httpNode.onError(ifNode.onTrue!(trueHandler).onFalse(falseHandler) as any); + const wf = workflow('test-id', 'Test').add(t).to(httpNode); + const json = wf.toJSON(); + + expect(json.nodes).toHaveLength(5); + expect(json.connections['Start']?.main[0]?.[0]?.node).toBe('HTTP'); + expect(json.connections['HTTP']?.main[1]?.[0]?.node).toBe('IF'); + expect(json.connections['IF']?.main[0]?.[0]?.node).toBe('True Handler'); + expect(json.connections['IF']?.main[1]?.[0]?.node).toBe('False Handler'); + }); + + it('should handle chain leading to composite via onError', () => { + const httpNode = node({ + type: 'n8n-nodes-base.httpRequest', + version: 4.2, + config: { name: 'HTTP', onError: 'continueErrorOutput' }, + }); + const logNode = node({ + type: 'n8n-nodes-base.set', + version: 3.4, + config: { name: 'Log' }, + }); + const ifNode = node({ + type: 'n8n-nodes-base.if', + version: 2, + config: { name: 'IF' }, + }) as NodeInstance<'n8n-nodes-base.if', string, unknown>; + const trueHandler = node({ + type: 'n8n-nodes-base.noOp', + version: 1, + config: { name: 'True Handler' }, + }); + const falseHandler = node({ + type: 'n8n-nodes-base.noOp', + version: 1, + config: { name: 'False Handler' }, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + httpNode.onError(logNode.to(ifNode.onTrue!(trueHandler).onFalse(falseHandler) as any)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wf = workflow('test-id', 'Test').add(httpNode as any); + const json = wf.toJSON(); + + expect(json.nodes).toHaveLength(5); + expect(json.connections['HTTP']?.main[1]?.[0]?.node).toBe('Log'); + expect(json.connections['Log']?.main[0]?.[0]?.node).toBe('IF'); + expect(json.connections['IF']?.main[0]?.[0]?.node).toBe('True Handler'); + expect(json.connections['IF']?.main[1]?.[0]?.node).toBe('False Handler'); + }); }); describe('.settings()', () => { diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder.ts b/packages/@n8n/workflow-sdk/src/workflow-builder.ts index b17978446d8..fc2181cf804 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder.ts @@ -700,8 +700,11 @@ class WorkflowBuilderImpl implements WorkflowBuilder { const registry = this._registry ?? pluginRegistry; const connections = chain.getConnections(); for (const { target } of connections) { - // Skip if target is a composite type (already handled by plugin dispatch elsewhere) - if (registry.isCompositeType(target)) continue; + // Dispatch composite types (e.g. IfElseBuilder) via plugin handler + if (registry.isCompositeType(target)) { + this.tryPluginDispatch(nodes, target, nameMapping); + continue; + } // Handle NodeChains - use addBranchToGraph to add all nodes with their connections if (isNodeChain(target)) { @@ -746,8 +749,11 @@ class WorkflowBuilderImpl implements WorkflowBuilder { const registry = this._registry ?? pluginRegistry; const connections = nodeInstance.getConnections(); for (const { target } of connections) { - // Skip if target is a composite type (already handled by plugin dispatch elsewhere) - if (registry.isCompositeType(target)) continue; + // Dispatch composite types (e.g. IfElseBuilder) via plugin handler + if (registry.isCompositeType(target)) { + this.tryPluginDispatch(nodes, target); + continue; + } // Handle NodeChains - use addBranchToGraph to add all nodes with their connections if (isNodeChain(target)) { @@ -1008,6 +1014,13 @@ class WorkflowBuilderImpl implements WorkflowBuilder { this.addNodeWithSubnodes(nodes, targetChainNode); } } + } else if (registry.isCompositeType(target)) { + // Only dispatch if the composite's head node isn't already in the graph + // (avoids re-dispatching composites already handled by the allNodes loop above) + const compositeHeadName = this.resolveTargetNodeName(target, effectiveNameMapping); + if (!compositeHeadName || !nodes.has(compositeHeadName)) { + this.tryPluginDispatch(nodes, target, effectiveNameMapping); + } } else if ( typeof (target as NodeInstance).name === 'string' && !nodes.has((target as NodeInstance).name)