diff --git a/packages/angular/build/src/tools/angular/linker/oxc-ast-host.ts b/packages/angular/build/src/tools/angular/linker/oxc-ast-host.ts index 5a11959fff66..245443781601 100644 --- a/packages/angular/build/src/tools/angular/linker/oxc-ast-host.ts +++ b/packages/angular/build/src/tools/angular/linker/oxc-ast-host.ts @@ -26,11 +26,20 @@ function isNode(node: unknown): node is Node { return typeof node === 'object' && node !== null && 'type' in node; } +function unwrapParentheses(node: unknown): unknown { + while (isNode(node) && node.type === 'ParenthesizedExpression') { + node = node.expression; + } + + return node; +} + /** * An implementation of `AstHost` that queries information from `oxc-parser` AST nodes. */ export class OxcAstHost implements AstHost { getSymbolName(node: unknown): string | null { + node = unwrapParentheses(node); if (!isNode(node)) { return null; } @@ -47,10 +56,13 @@ export class OxcAstHost implements AstHost { } isStringLiteral(node: unknown): node is StringLiteral { + node = unwrapParentheses(node); + return isNode(node) && node.type === 'Literal' && typeof node.value === 'string'; } parseStringLiteral(str: unknown): string { + str = unwrapParentheses(str); if (!this.isStringLiteral(str)) { throw new FatalLinkerError(str as object, 'Unsupported syntax, expected a string literal.'); } @@ -59,10 +71,13 @@ export class OxcAstHost implements AstHost { } isNumericLiteral(node: unknown): node is NumericLiteral { + node = unwrapParentheses(node); + return isNode(node) && node.type === 'Literal' && typeof node.value === 'number'; } parseNumericLiteral(num: unknown): number { + num = unwrapParentheses(num); if (!this.isNumericLiteral(num)) { throw new FatalLinkerError(num as object, 'Unsupported syntax, expected a numeric literal.'); } @@ -71,6 +86,7 @@ export class OxcAstHost implements AstHost { } isBooleanLiteral(node: unknown): node is BooleanLiteral | UnaryExpression { + node = unwrapParentheses(node); if (!isNode(node)) { return false; } @@ -81,6 +97,7 @@ export class OxcAstHost implements AstHost { } parseBooleanLiteral(bool: unknown): boolean { + bool = unwrapParentheses(bool); if (isNode(bool)) { if (bool.type === 'Literal' && typeof bool.value === 'boolean') { return bool.value; @@ -94,14 +111,19 @@ export class OxcAstHost implements AstHost { } isNull(node: unknown): node is NullLiteral { + node = unwrapParentheses(node); + return isNode(node) && node.type === 'Literal' && node.value === null; } isArrayLiteral(node: unknown): node is ArrayExpression { + node = unwrapParentheses(node); + return isNode(node) && node.type === 'ArrayExpression'; } parseArrayLiteral(array: unknown): unknown[] { + array = unwrapParentheses(array); if (!this.isArrayLiteral(array)) { throw new FatalLinkerError(array as object, 'Unsupported syntax, expected an array literal.'); } @@ -115,23 +137,27 @@ export class OxcAstHost implements AstHost { 'Unsupported syntax, element in array not to be empty.', ); } - if (element.type === 'SpreadElement') { + const unwrappedElement = unwrapParentheses(element); + if (isNode(unwrappedElement) && unwrappedElement.type === 'SpreadElement') { throw new FatalLinkerError( - element as object, + unwrappedElement as object, 'Unsupported syntax, element in array not to use spread syntax.', ); } - result.push(element); + result.push(unwrappedElement); } return result; } isObjectLiteral(node: unknown): node is ObjectExpression { + node = unwrapParentheses(node); + return isNode(node) && node.type === 'ObjectExpression'; } parseObjectLiteral(obj: unknown): Map { + obj = unwrapParentheses(obj); if (!this.isObjectLiteral(obj)) { throw new FatalLinkerError(obj as object, 'Unsupported syntax, expected an object literal.'); } @@ -146,7 +172,13 @@ export class OxcAstHost implements AstHost { ); } - const keyNode = property.key; + const keyNode = unwrapParentheses(property.key); + if (!isNode(keyNode)) { + throw new FatalLinkerError( + property.key as object, + 'Unsupported syntax, expected a property name.', + ); + } let key: string; if (keyNode.type === 'Identifier') { @@ -162,13 +194,14 @@ export class OxcAstHost implements AstHost { ); } - result.set(key, property.value); + result.set(key, unwrapParentheses(property.value)); } return result; } isFunctionExpression(node: unknown): node is FunctionNode | ArrowFunctionExpression { + node = unwrapParentheses(node); if (!isNode(node)) { return false; } @@ -181,6 +214,7 @@ export class OxcAstHost implements AstHost { } parseReturnValue(fn: unknown): unknown { + fn = unwrapParentheses(fn); if (!this.isFunctionExpression(fn)) { throw new FatalLinkerError(fn as object, 'Unsupported syntax, expected a function.'); } @@ -191,7 +225,7 @@ export class OxcAstHost implements AstHost { } if (body.type !== 'BlockStatement') { - return body; + return unwrapParentheses(body); } const statements = body.body; @@ -217,10 +251,11 @@ export class OxcAstHost implements AstHost { ); } - return stmt.argument; + return unwrapParentheses(stmt.argument); } parseParameters(fn: unknown): unknown[] { + fn = unwrapParentheses(fn); if (!this.isFunctionExpression(fn)) { throw new FatalLinkerError(fn as object, 'Unsupported syntax, expected a function.'); } @@ -229,18 +264,22 @@ export class OxcAstHost implements AstHost { } isCallExpression(node: unknown): node is CallExpression { + node = unwrapParentheses(node); + return isNode(node) && node.type === 'CallExpression'; } parseCallee(call: unknown): unknown { + call = unwrapParentheses(call); if (!this.isCallExpression(call)) { throw new FatalLinkerError(call as object, 'Unsupported syntax, expected a call expression.'); } - return call.callee; + return unwrapParentheses(call.callee); } parseArguments(call: unknown): unknown[] { + call = unwrapParentheses(call); if (!this.isCallExpression(call)) { throw new FatalLinkerError(call as object, 'Unsupported syntax, expected a call expression.'); } @@ -248,19 +287,21 @@ export class OxcAstHost implements AstHost { const result: unknown[] = []; for (const arg of call.arguments) { - if (arg.type === 'SpreadElement') { + const unwrappedArg = unwrapParentheses(arg); + if (isNode(unwrappedArg) && unwrappedArg.type === 'SpreadElement') { throw new FatalLinkerError( - arg as object, + unwrappedArg as object, 'Unsupported syntax, argument not to use spread syntax.', ); } - result.push(arg); + result.push(unwrappedArg); } return result; } getRange(node: unknown): Range { + node = unwrapParentheses(node); if (!isNode(node) || typeof node.start !== 'number' || typeof node.end !== 'number') { throw new FatalLinkerError( node as object, diff --git a/packages/angular/build/src/tools/angular/linker/oxc-ast-host_spec.ts b/packages/angular/build/src/tools/angular/linker/oxc-ast-host_spec.ts index 79027584066d..cbe0d6a25292 100644 --- a/packages/angular/build/src/tools/angular/linker/oxc-ast-host_spec.ts +++ b/packages/angular/build/src/tools/angular/linker/oxc-ast-host_spec.ts @@ -34,11 +34,13 @@ describe('OxcAstHost', () => { it('should return the name of an identifier', () => { const expr = parseExpression('foo'); expect(host.getSymbolName(expr)).toBe('foo'); + expect(host.getSymbolName(parseExpression('(foo)'))).toBe('foo'); }); it('should return the property name of a member expression', () => { const expr = parseExpression('foo.bar'); expect(host.getSymbolName(expr)).toBe('bar'); + expect(host.getSymbolName(parseExpression('(foo.bar)'))).toBe('bar'); }); it('should return null for non-identifier or computed member expressions', () => { @@ -53,6 +55,10 @@ describe('OxcAstHost', () => { const expr = parseExpression('"hello"'); expect(host.isStringLiteral(expr)).toBe(true); expect(host.parseStringLiteral(expr)).toBe('hello'); + + const parenthesized = parseExpression('("hello")'); + expect(host.isStringLiteral(parenthesized)).toBe(true); + expect(host.parseStringLiteral(parenthesized)).toBe('hello'); }); it('should throw when parsing non-string literals', () => { @@ -67,6 +73,10 @@ describe('OxcAstHost', () => { const expr = parseExpression('123'); expect(host.isNumericLiteral(expr)).toBe(true); expect(host.parseNumericLiteral(expr)).toBe(123); + + const parenthesized = parseExpression('(123)'); + expect(host.isNumericLiteral(parenthesized)).toBe(true); + expect(host.parseNumericLiteral(parenthesized)).toBe(123); }); it('should throw when parsing non-numeric literals', () => { @@ -84,6 +94,10 @@ describe('OxcAstHost', () => { expect(host.parseBooleanLiteral(trueExpr)).toBe(true); expect(host.isBooleanLiteral(falseExpr)).toBe(true); expect(host.parseBooleanLiteral(falseExpr)).toBe(false); + + const parenthesizedTrue = parseExpression('(true)'); + expect(host.isBooleanLiteral(parenthesizedTrue)).toBe(true); + expect(host.parseBooleanLiteral(parenthesizedTrue)).toBe(true); }); it('should recognize and parse minified boolean literals (!0 and !1)', () => { @@ -93,6 +107,10 @@ describe('OxcAstHost', () => { expect(host.parseBooleanLiteral(trueExpr)).toBe(true); expect(host.isBooleanLiteral(falseExpr)).toBe(true); expect(host.parseBooleanLiteral(falseExpr)).toBe(false); + + const parenthesizedMinified = parseExpression('(!0)'); + expect(host.isBooleanLiteral(parenthesizedMinified)).toBe(true); + expect(host.parseBooleanLiteral(parenthesizedMinified)).toBe(true); }); it('should return false for invalid boolean expressions', () => { @@ -106,6 +124,18 @@ describe('OxcAstHost', () => { const expr = parseExpression('[1, "a", true]'); expect(host.isArrayLiteral(expr)).toBe(true); expect(host.parseArrayLiteral(expr).length).toBe(3); + + const parenthesized = parseExpression('([1, "a", true])'); + expect(host.isArrayLiteral(parenthesized)).toBe(true); + expect(host.parseArrayLiteral(parenthesized).length).toBe(3); + }); + + it('should unwrap parenthesized elements in array literals', () => { + const expr = parseExpression('[(1), ("a")]'); + const elements = host.parseArrayLiteral(expr); + expect(elements.length).toBe(2); + expect(host.isNumericLiteral(elements[0])).toBe(true); + expect(host.isStringLiteral(elements[1])).toBe(true); }); it('should throw when array contains empty elements or spread syntax', () => { @@ -115,6 +145,9 @@ describe('OxcAstHost', () => { expect(() => host.parseArrayLiteral(parseExpression('[1, ...a]'))).toThrowError( FatalLinkerError, ); + expect(() => host.parseArrayLiteral(parseExpression('[1, ...(a)]'))).toThrowError( + FatalLinkerError, + ); }); }); @@ -130,6 +163,16 @@ describe('OxcAstHost', () => { expect(map.has('3')).toBe(true); }); + it('should recognize and parse parenthesized object literals', () => { + const expr = parseExpression('({ a: (1), b: ("c") })'); + expect(host.isObjectLiteral(expr)).toBe(true); + + const map = host.parseObjectLiteral(expr); + expect(map.size).toBe(2); + expect(host.isNumericLiteral(map.get('a'))).toBe(true); + expect(host.isStringLiteral(map.get('b'))).toBe(true); + }); + it('should throw when object literal contains spread or non-property assignments', () => { expect(() => host.parseObjectLiteral(parseExpression('{ ...a }'))).toThrowError( FatalLinkerError, @@ -147,6 +190,21 @@ describe('OxcAstHost', () => { expect(host.isNumericLiteral(returnValue)).toBe(true); }); + it('should parse parenthesized arrow functions and concise bodies returning parenthesized object literals', () => { + const expr = parseExpression('() => ({ a: 1 })'); + expect(host.isFunctionExpression(expr)).toBe(true); + + const returnValue = host.parseReturnValue(expr); + expect(host.isObjectLiteral(returnValue)).toBe(true); + const map = host.parseObjectLiteral(returnValue); + expect(map.has('a')).toBe(true); + + const wrappedArrow = parseExpression('((a) => (42))'); + expect(host.isFunctionExpression(wrappedArrow)).toBe(true); + expect(host.parseParameters(wrappedArrow).length).toBe(1); + expect(host.isNumericLiteral(host.parseReturnValue(wrappedArrow))).toBe(true); + }); + it('should parse return value from function with block statement containing single return', () => { const stmt = parseStatement('function foo(a) { return "hello"; }'); expect(host.isFunctionExpression(stmt)).toBe(true); @@ -155,6 +213,14 @@ describe('OxcAstHost', () => { expect(host.isStringLiteral(returnValue)).toBe(true); }); + it('should parse return value from function returning parenthesized expression', () => { + const stmt = parseStatement('function foo() { return ({ a: 1 }); }'); + expect(host.isFunctionExpression(stmt)).toBe(true); + + const returnValue = host.parseReturnValue(stmt); + expect(host.isObjectLiteral(returnValue)).toBe(true); + }); + it('should throw when function body has multiple statements or no return', () => { expect(() => host.parseReturnValue(parseStatement('function foo() { const x = 1; return x; }')), @@ -171,6 +237,14 @@ describe('OxcAstHost', () => { expect(host.isCallExpression(expr)).toBe(true); expect(host.getSymbolName(host.parseCallee(expr))).toBe('foo'); expect(host.parseArguments(expr).length).toBe(2); + + const parenthesized = parseExpression('((foo)((1), ("a")))'); + expect(host.isCallExpression(parenthesized)).toBe(true); + expect(host.getSymbolName(host.parseCallee(parenthesized))).toBe('foo'); + const args = host.parseArguments(parenthesized); + expect(args.length).toBe(2); + expect(host.isNumericLiteral(args[0])).toBe(true); + expect(host.isStringLiteral(args[1])).toBe(true); }); it('should throw when call expression arguments contain spread syntax', () => { diff --git a/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts b/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts index fa62c1c5ece1..467658eb1991 100644 --- a/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts +++ b/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts @@ -76,4 +76,27 @@ describe('oxc-linker', () => { expect(result.map?.version).toBe(3); expect(result.map?.sources).toContain('test.js'); }); + + it('should link ɵɵngDeclareClassMetadataAsync with parenthesized resolveMetadata return', () => { + const input = ` + import * as i0 from "@angular/core"; + export class DeferredFixture {} + i0.ɵɵngDeclareClassMetadataAsync({ + minVersion: "18.0.0", + version: "22.1.7", + ngImport: i0, + type: DeferredFixture, + resolveDeferredDeps: () => [], + resolveMetadata: () => ({ + decorators: [], + ctorParameters: null, + propDecorators: null, + }), + }); + `; + + const result = transform('test.js', input, { link: true, advancedOptimizations: false }); + expect(result.code).toContain('ɵsetClassMetadataAsync'); + expect(result.code).not.toContain('ɵɵngDeclareClassMetadataAsync'); + }); });