Skip to content

Commit 0f4b11c

Browse files
hawkgskirjs
authored andcommitted
refactor(compiler-cli): add a resource debugName transform (#64172)
Add a TS transform for `resource` (and `httpResource`) `debugName`. Test the transformations. PR Close #64172
1 parent 3ae452e commit 0f4b11c

File tree

13 files changed

+1380
-176
lines changed

13 files changed

+1380
-176
lines changed

packages/compiler-cli/src/ngtsc/testing/fake_common/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,6 @@ ng_package(
1919
package = "@angular/common",
2020
deps = [
2121
":fake_common",
22+
"//packages/compiler-cli/src/ngtsc/testing/fake_common/http:fake_http",
2223
],
2324
)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
load("//tools:defaults.bzl", "ts_project")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ts_project(
6+
name = "fake_http",
7+
srcs = ["index.ts"],
8+
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
// Fake Http package with partial API coverage. Modify as needed.
10+
11+
export type HttpResourceRef<T> = any;
12+
13+
export declare function httpResource(...args: any): HttpResourceRef<any>;

packages/compiler-cli/src/ngtsc/transform/src/implicit_signal_debug_name_transform.ts

Lines changed: 86 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -15,31 +15,27 @@ function insertDebugNameIntoCallExpression(
1515
const signalExpressionIsRequired = isRequiredSignalFunction(callExpression.expression);
1616
let configPosition = signalExpressionIsRequired ? 0 : 1;
1717

18-
const nodeArgs = Array.from(callExpression.arguments);
19-
2018
// 1. If the call expression has no arguments, we pretend that the config object is at position 0.
2119
// We do this so that we can insert a spread element at the start of the args list in a way where
2220
// undefined can be the first argument but still get tree-shaken out in production builds.
2321
// or
24-
// 2. Since `linkedSignal` with computation uses a single object for both computation logic
25-
// and options (unlike other signal-based primitives), we set the argument position to 0, i.e.
26-
// reusing the computation logic object.
22+
// 2. If the signal has an object-only definition (e.g. `linkedSignal` or `resource`), we set
23+
// the argument position to 0, i.e. reusing the existing object.
2724
const signalExpressionHasNoArguments = callExpression.arguments.length === 0;
28-
const isLinkedSignal = callExpression.expression.getText() === 'linkedSignal';
29-
const isComputationLinkedSignal =
30-
isLinkedSignal && nodeArgs[0].kind === ts.SyntaxKind.ObjectLiteralExpression;
31-
if (signalExpressionHasNoArguments || isComputationLinkedSignal) {
25+
const signalWithObjectOnlyDefinition = isSignalWithObjectOnlyDefinition(callExpression);
26+
if (signalExpressionHasNoArguments || signalWithObjectOnlyDefinition) {
3227
configPosition = 0;
3328
}
3429

30+
const nodeArgs = Array.from(callExpression.arguments);
3531
let existingArgument = nodeArgs[configPosition];
3632

3733
if (existingArgument === undefined) {
3834
existingArgument = ts.factory.createObjectLiteralExpression([]);
3935
}
4036

4137
// Do nothing if an identifier is used as the config object
42-
// Ex -
38+
// Ex:
4339
// const defaultObject = { equals: () => false };
4440
// signal(123, defaultObject)
4541
if (ts.isIdentifier(existingArgument)) {
@@ -50,7 +46,7 @@ function insertDebugNameIntoCallExpression(
5046
return callExpression;
5147
}
5248

53-
// insert debugName into the existing config object
49+
// Insert debugName into the existing config object
5450
const properties = Array.from(existingArgument.properties);
5551
const debugNameExists = properties.some(
5652
(prop) =>
@@ -61,73 +57,52 @@ function insertDebugNameIntoCallExpression(
6157
return callExpression;
6258
}
6359

64-
// We prepend instead of appending so that we don't overwrite an existing debugName Property
65-
// `{ foo: 'bar' }` -> `{ debugName: 'myDebugName', foo: 'bar' }`
66-
properties.unshift(
67-
ts.factory.createPropertyAssignment('debugName', ts.factory.createStringLiteral(debugName)),
68-
);
69-
70-
const transformedConfigProperties = ts.factory.createObjectLiteralExpression(properties);
7160
const ngDevModeIdentifier = ts.factory.createIdentifier('ngDevMode');
61+
const debugNameProperty = ts.factory.createPropertyAssignment(
62+
'debugName',
63+
ts.factory.createStringLiteral(debugName),
64+
);
65+
const debugNameObject = ts.factory.createObjectLiteralExpression([debugNameProperty]);
66+
const emptyObject = ts.factory.createObjectLiteralExpression();
7267

73-
let devModeCase: ts.ArrayLiteralExpression;
74-
// if the signal expression has no arguments and the config object is not required,
75-
// we need to add an undefined identifier to the start of the args list so that we can spread the
76-
// config object in the right place.
77-
if (signalExpressionHasNoArguments && !signalExpressionIsRequired) {
78-
devModeCase = ts.factory.createArrayLiteralExpression([
79-
ts.factory.createIdentifier('undefined'),
80-
transformedConfigProperties,
81-
]);
82-
} else {
83-
devModeCase = ts.factory.createArrayLiteralExpression([
84-
transformedConfigProperties,
85-
...nodeArgs.slice(configPosition + 1),
86-
]);
87-
}
88-
89-
const nonDevModeCase = signalExpressionIsRequired
90-
? ts.factory.createArrayLiteralExpression(nodeArgs)
91-
: ts.factory.createArrayLiteralExpression(nodeArgs.slice(configPosition));
92-
93-
const spreadElementContainingUpdatedOptions = ts.factory.createSpreadElement(
68+
// Create the spread expression: `...(ngDevMode ? { debugName: 'myDebugName' } : {})`
69+
const spreadDebugNameExpression = ts.factory.createSpreadAssignment(
9470
ts.factory.createParenthesizedExpression(
9571
ts.factory.createConditionalExpression(
9672
ngDevModeIdentifier,
97-
/* question token */ undefined,
98-
devModeCase,
99-
/* colon token */ undefined,
100-
nonDevModeCase,
73+
undefined, // Question token
74+
debugNameObject,
75+
undefined, // Colon token
76+
emptyObject,
10177
),
10278
),
10379
);
10480

105-
let transformedSignalArgs: ts.NodeArray<ts.Expression>;
106-
107-
if (signalExpressionIsRequired || signalExpressionHasNoArguments || isComputationLinkedSignal) {
108-
// 1. If the call expression is a required signal function, there is no args other than the config object.
109-
// So we just use the spread element as the only argument.
110-
// or
111-
// 2. If the call expression has no arguments (ex. input(), model(), etc), we already added the undefined
112-
// identifier in the spread element above. So we use that spread Element as is.
113-
// or
114-
// 3. We are transforming a `linkedSignal` with computation (i.e. we have a single object for both
115-
// logic and options).
116-
transformedSignalArgs = ts.factory.createNodeArray([spreadElementContainingUpdatedOptions]);
81+
const transformedConfigProperties = ts.factory.createObjectLiteralExpression([
82+
spreadDebugNameExpression,
83+
...properties,
84+
]);
85+
86+
let transformedSignalArgs = [];
87+
88+
// The following expression handles 3 cases:
89+
// 1. Non-`required` signals without an argument that need to be prepended with `undefined` (e.g. `model()`).
90+
// 2. Signals with object-only definition like `resource` or `linkedSignal` with computation;
91+
// Or `required` signals.
92+
// 3. All remaining cases where we have a signal with an argument (e.g `computed(Fn)` or `signal('foo')`).
93+
if (signalExpressionHasNoArguments && !signalExpressionIsRequired) {
94+
transformedSignalArgs = [ts.factory.createIdentifier('undefined'), transformedConfigProperties];
95+
} else if (signalWithObjectOnlyDefinition || signalExpressionIsRequired) {
96+
transformedSignalArgs = [transformedConfigProperties];
11797
} else {
118-
// 3. Signal expression is not required and has arguments.
119-
// Here we leave the first argument as is and spread the rest.
120-
transformedSignalArgs = ts.factory.createNodeArray([
121-
nodeArgs[0],
122-
spreadElementContainingUpdatedOptions,
123-
]);
98+
transformedSignalArgs = [nodeArgs[0], transformedConfigProperties];
12499
}
125100

126101
return ts.factory.updateCallExpression(
127102
callExpression,
128103
callExpression.expression,
129104
callExpression.typeArguments,
130-
transformedSignalArgs,
105+
ts.factory.createNodeArray(transformedSignalArgs),
131106
);
132107
}
133108

@@ -234,6 +209,23 @@ function isPropertyDeclarationCase(
234209
return ts.isIdentifier(expression) && isSignalFunction(expression);
235210
}
236211

212+
type PackageName = 'core' | 'common';
213+
214+
const signalFunctions: ReadonlyMap<string, PackageName> = new Map([
215+
['signal', 'core'],
216+
['computed', 'core'],
217+
['linkedSignal', 'core'],
218+
['input', 'core'],
219+
['model', 'core'],
220+
['viewChild', 'core'],
221+
['viewChildren', 'core'],
222+
['contentChild', 'core'],
223+
['contentChildren', 'core'],
224+
['effect', 'core'],
225+
['resource', 'core'],
226+
['httpResource', 'common'],
227+
]);
228+
237229
/**
238230
*
239231
* Determines if a node is an expression that references an @angular/core imported symbol.
@@ -243,7 +235,7 @@ function isPropertyDeclarationCase(
243235
* const mySignal = signal(123); // expressionIsUsingAngularImportedSymbol === true
244236
* ```
245237
*/
246-
function expressionIsUsingAngularCoreImportedSymbol(
238+
function expressionIsUsingAngularImportedSymbol(
247239
program: ts.Program,
248240
expression: ts.Expression,
249241
): boolean {
@@ -282,25 +274,14 @@ function expressionIsUsingAngularCoreImportedSymbol(
282274
}
283275

284276
const specifier = importDeclaration.moduleSpecifier.text;
277+
const packageName = signalFunctions.get(expression.getText());
285278
return (
286279
specifier !== undefined &&
287-
(specifier === '@angular/core' || specifier.startsWith('@angular/core/'))
280+
packageName !== undefined &&
281+
(specifier === `@angular/${packageName}` || specifier.startsWith(`@angular/${packageName}/`))
288282
);
289283
}
290284

291-
const signalFunctions: ReadonlySet<string> = new Set([
292-
'signal',
293-
'computed',
294-
'linkedSignal',
295-
'input',
296-
'model',
297-
'viewChild',
298-
'viewChildren',
299-
'contentChild',
300-
'contentChildren',
301-
'effect',
302-
]);
303-
304285
function isSignalFunction(expression: ts.Identifier): boolean {
305286
const text = expression.text;
306287

@@ -331,10 +312,10 @@ function transformVariableDeclaration(
331312

332313
const expression = node.initializer.expression;
333314
if (ts.isPropertyAccessExpression(expression)) {
334-
if (!expressionIsUsingAngularCoreImportedSymbol(program, expression.expression)) {
315+
if (!expressionIsUsingAngularImportedSymbol(program, expression.expression)) {
335316
return node;
336317
}
337-
} else if (!expressionIsUsingAngularCoreImportedSymbol(program, expression)) {
318+
} else if (!expressionIsUsingAngularImportedSymbol(program, expression)) {
338319
return node;
339320
}
340321

@@ -357,15 +338,18 @@ function transformVariableDeclaration(
357338
function transformPropertyAssignment(
358339
program: ts.Program,
359340
node: ts.ExpressionStatement & {
360-
expression: ts.BinaryExpression & {right: ts.CallExpression; left: ts.PropertyAccessExpression};
341+
expression: ts.BinaryExpression & {
342+
right: ts.CallExpression;
343+
left: ts.PropertyAccessExpression;
344+
};
361345
},
362346
): ts.ExpressionStatement {
363347
const expression = node.expression.right.expression;
364348
if (ts.isPropertyAccessExpression(expression)) {
365-
if (!expressionIsUsingAngularCoreImportedSymbol(program, expression.expression)) {
349+
if (!expressionIsUsingAngularImportedSymbol(program, expression.expression)) {
366350
return node;
367351
}
368-
} else if (!expressionIsUsingAngularCoreImportedSymbol(program, expression)) {
352+
} else if (!expressionIsUsingAngularImportedSymbol(program, expression)) {
369353
return node;
370354
}
371355

@@ -387,10 +371,10 @@ function transformPropertyDeclaration(
387371

388372
const expression = node.initializer.expression;
389373
if (ts.isPropertyAccessExpression(expression)) {
390-
if (!expressionIsUsingAngularCoreImportedSymbol(program, expression.expression)) {
374+
if (!expressionIsUsingAngularImportedSymbol(program, expression.expression)) {
391375
return node;
392376
}
393-
} else if (!expressionIsUsingAngularCoreImportedSymbol(program, expression)) {
377+
} else if (!expressionIsUsingAngularImportedSymbol(program, expression)) {
394378
return node;
395379
}
396380

@@ -410,6 +394,24 @@ function transformPropertyDeclaration(
410394
}
411395
}
412396

397+
/**
398+
* The function determines whether the target signal has an object-only definition, that includes
399+
* both the computation logic and the options (unlike other signal-based primitives), or not.
400+
* Ex: `linkedSignal` with computation, `resource`
401+
*/
402+
function isSignalWithObjectOnlyDefinition(callExpression: ts.CallExpression): boolean {
403+
const callExpressionText = callExpression.expression.getText();
404+
const nodeArgs = Array.from(callExpression.arguments);
405+
406+
const isLinkedSignal = callExpressionText === 'linkedSignal';
407+
const isComputationLinkedSignal =
408+
isLinkedSignal && nodeArgs[0].kind === ts.SyntaxKind.ObjectLiteralExpression;
409+
410+
const isResource = callExpressionText === 'resource';
411+
412+
return isComputationLinkedSignal || isResource;
413+
}
414+
413415
/**
414416
*
415417
* This transformer adds a debugName property to the config object of signal functions like

packages/compiler-cli/test/compliance/test_cases/model_inputs/GOLDEN_PARTIAL.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { Directive, model } from '@angular/core';
55
import * as i0 from "@angular/core";
66
export class TestDir {
77
constructor() {
8-
this.counter = model(0, ...(ngDevMode ? [{ debugName: "counter" }] : []));
9-
this.name = model.required(...(ngDevMode ? [{ debugName: "name" }] : []));
8+
this.counter = model(0, Object.assign({}, (ngDevMode ? { debugName: "counter" } : {})));
9+
this.name = model.required(Object.assign({}, (ngDevMode ? { debugName: "name" } : {})));
1010
}
1111
}
1212
TestDir.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestDir, deps: [], target: i0.ɵɵFactoryTarget.Directive });
@@ -34,8 +34,8 @@ import { Component, model } from '@angular/core';
3434
import * as i0 from "@angular/core";
3535
export class TestComp {
3636
constructor() {
37-
this.counter = model(0, ...(ngDevMode ? [{ debugName: "counter" }] : []));
38-
this.name = model.required(...(ngDevMode ? [{ debugName: "name" }] : []));
37+
this.counter = model(0, Object.assign({}, (ngDevMode ? { debugName: "counter" } : {})));
38+
this.name = model.required(Object.assign({}, (ngDevMode ? { debugName: "name" } : {})));
3939
}
4040
}
4141
TestComp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestComp, deps: [], target: i0.ɵɵFactoryTarget.Component });
@@ -65,8 +65,8 @@ import { Directive, EventEmitter, Input, model, Output } from '@angular/core';
6565
import * as i0 from "@angular/core";
6666
export class TestDir {
6767
constructor() {
68-
this.counter = model(0, ...(ngDevMode ? [{ debugName: "counter" }] : []));
69-
this.modelWithAlias = model(false, ...(ngDevMode ? [{ debugName: "modelWithAlias", alias: 'alias' }] : [{ alias: 'alias' }]));
68+
this.counter = model(0, Object.assign({}, (ngDevMode ? { debugName: "counter" } : {})));
69+
this.modelWithAlias = model(false, Object.assign(Object.assign({}, (ngDevMode ? { debugName: "modelWithAlias" } : {})), { alias: 'alias' }));
7070
this.decoratorInput = true;
7171
this.decoratorInputWithAlias = true;
7272
this.decoratorOutput = new EventEmitter();

packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/animations/GOLDEN_PARTIAL.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ import { Component, signal } from '@angular/core';
188188
import * as i0 from "@angular/core";
189189
export class MyComponent {
190190
constructor() {
191-
this.enterClass = signal('slide', ...(ngDevMode ? [{ debugName: "enterClass" }] : []));
191+
this.enterClass = signal('slide', Object.assign({}, (ngDevMode ? { debugName: "enterClass" } : {})));
192192
}
193193
}
194194
MyComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
@@ -299,7 +299,7 @@ import { Component, signal } from '@angular/core';
299299
import * as i0 from "@angular/core";
300300
export class MyComponent {
301301
constructor() {
302-
this.leaveClass = signal('fade', ...(ngDevMode ? [{ debugName: "leaveClass" }] : []));
302+
this.leaveClass = signal('fade', Object.assign({}, (ngDevMode ? { debugName: "leaveClass" } : {})));
303303
}
304304
}
305305
MyComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });

packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/GOLDEN_PARTIAL.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Component, Directive, input } from '@angular/core';
55
import * as i0 from "@angular/core";
66
export class Field {
77
constructor() {
8-
this.field = input(...(ngDevMode ? [undefined, { debugName: "field" }] : []));
8+
this.field = input(undefined, Object.assign({}, (ngDevMode ? { debugName: "field" } : {})));
99
}
1010
}
1111
Field.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: Field, deps: [], target: i0.ɵɵFactoryTarget.Directive });

packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/GOLDEN_PARTIAL.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -967,7 +967,7 @@ import { Component, Directive, model, signal } from '@angular/core';
967967
import * as i0 from "@angular/core";
968968
export class NgModelDirective {
969969
constructor() {
970-
this.ngModel = model.required(...(ngDevMode ? [{ debugName: "ngModel" }] : []));
970+
this.ngModel = model.required(Object.assign({}, (ngDevMode ? { debugName: "ngModel" } : {})));
971971
}
972972
}
973973
NgModelDirective.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: NgModelDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
@@ -1023,7 +1023,7 @@ import { Component, Directive, model } from '@angular/core';
10231023
import * as i0 from "@angular/core";
10241024
export class NgModelDirective {
10251025
constructor() {
1026-
this.ngModel = model('', ...(ngDevMode ? [{ debugName: "ngModel" }] : []));
1026+
this.ngModel = model('', Object.assign({}, (ngDevMode ? { debugName: "ngModel" } : {})));
10271027
}
10281028
}
10291029
NgModelDirective.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: NgModelDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });

0 commit comments

Comments
 (0)