fix(core): Keep parentheses around && and || operands of ?? in expressions (#40962)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Florian Gößler 2026-10-20 09:35:06 +00:00 committed by GitHub
parent e5d3f0f9f4
commit dbcfecf5b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 40 additions and 0 deletions

View File

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

View File

@ -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', () => {