From dbcfecf5b9035b239df4b657ee4ac77efee7030e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20G=C3=B6=C3=9Fler?= Date: Tue, 20 Oct 2026 09:35:06 +0000 Subject: [PATCH] fix(core): Keep parentheses around && and || operands of ?? in expressions (#40962) Co-authored-by: Claude Opus 5.5 Co-authored-by: Cursor --- .../src/extensions/expression-extension.ts | 17 ++++++++++++++ .../expression-extension.test.ts | 23 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/packages/workflow/src/extensions/expression-extension.ts b/packages/workflow/src/extensions/expression-extension.ts index 9b2dd1a4c9f..7884c2a6601 100644 --- a/packages/workflow/src/extensions/expression-extension.ts +++ b/packages/workflow/src/extensions/expression-extension.ts @@ -431,6 +431,23 @@ export const extendTransform = (expression: string): { code: string } | undefine }, }); + // recast adds parentheses by operator precedence only, but JavaScript + // rejects `&&` or `||` as an operand of `??` without parentheses. + // The ast-types definitions do not declare `extra`, which recast reads. + visit(ast, { + visitLogicalExpression(path) { + this.traverse(path); + + if (path.node.operator !== '??') return; + + for (const operand of [path.node.left, path.node.right]) { + if (operand.type === 'LogicalExpression' && operand.operator !== '??') { + Object.assign(operand, { extra: { parenthesized: true } }); + } + } + }, + }); + return print(ast); } catch (e) { return; diff --git a/packages/workflow/test/ExpressionExtensions/expression-extension.test.ts b/packages/workflow/test/ExpressionExtensions/expression-extension.test.ts index 2361173befd..8dd33afbaae 100644 --- a/packages/workflow/test/ExpressionExtensions/expression-extension.test.ts +++ b/packages/workflow/test/ExpressionExtensions/expression-extension.test.ts @@ -189,6 +189,29 @@ describe('Expression Parser', () => { test('Optional chaining with undefined argument', () => { expect(evaluate('={{ [1, 2, 3, 4]?.sum((undefined)?.test) }}')).toBe(10); }); + + describe('Nullish coalescing with an && or || operand next to an optional chain', () => { + test.each([ + ['$json.a?.[0] ?? ($json.a.last() && 1)', { a: [5, 6] }, 5], + ['$json.a?.[0] ?? ($json.a.last() && 1)', { a: [null, 6] }, 1], + ['$json.a?.[0] ?? ($json.a.last() || 1)', { a: [null, 0] }, 1], + ['($json.a.last() && 1) ?? $json.a?.[0]', { a: [5, 6] }, 1], + ['($json.a?.[0] || $json.b) ?? $json.a.last()', { a: [0, 6], b: null }, 6], + ['$json.a?.[0] ?? ($json.b && ($json.c || $json.a.last()))', { a: [null, 6], b: 1 }, 6], + ])('should keep the parentheses around the operand of "??" in %s', (code, data, result) => { + expect(extendTransform(code)?.code).toBeDefined(); + expect(evaluate(`={{ ${code} }}`, [data])).toBe(result); + }); + + test('should print the parentheses once', () => { + expect(extendTransform('$json.a?.[0] ?? ($json.a.last() && 1)')?.code).toBe( + '(window.chainCancelToken1 = ((window.chainValue1 = $json.a) ?? undefined) === undefined, window.chainCancelToken1 === true ? undefined : window.chainValue1[0]) ?? (extend($json.a, "last", []) && 1)', + ); + expect(extendTransform('1 ?? ($json.a.last() && 2)')?.code).toBe( + '1 ?? (extend($json.a, "last", []) && 2)', + ); + }); + }); }); describe('Non dot extensions', () => {