Skip to content

Commit 7c04df7

Browse files
committed
build: add a script to detect component ID collisions
As of v16, the framework uses the metadata of a component to generate an ID. If two components have identical metadata, they may end up with the same ID which will log a warning. These changes add a script to detect such cases automatically. Relates to #27163.
1 parent a320042 commit 7c04df7

File tree

2 files changed

+202
-0
lines changed

2 files changed

+202
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"format": "yarn -s ng-dev format changed",
3535
"cherry-pick-patch": "ts-node --project tools/cherry-pick-patch/tsconfig.json tools/cherry-pick-patch/cherry-pick-patch.ts",
3636
"ownerslint": "ts-node --project scripts/tsconfig.json scripts/ownerslint.ts",
37+
"detect-component-id-collisions": "ts-node --project scripts/tsconfig.json scripts/detect-component-id-collisions.ts",
3738
"tslint": "tslint -c tslint.json --project ./tsconfig.json",
3839
"stylelint": "stylelint \"src/**/*.+(css|scss)\" --config .stylelintrc.json",
3940
"resync-caretaker-app": "ts-node --project scripts/tsconfig.json scripts/caretaking/resync-caretaker-app-prs.ts",
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import ts from 'typescript';
2+
import chalk from 'chalk';
3+
import {readFileSync} from 'fs';
4+
import {join} from 'path';
5+
import {sync as glob} from 'glob';
6+
7+
// This script aims to detect if the unique IDs of two components may collide at runtime
8+
// in order to avoid issues like https://github.com/angular/components/issues/27163.
9+
10+
const errors: string[] = [];
11+
const seenMetadata = new Map<string, ts.ClassDeclaration>();
12+
const fileToCheck = join(__dirname, '../src/**/!(*.spec).ts');
13+
const ignoredPatterns = [
14+
'**/components-examples/**',
15+
'**/dev-app/**',
16+
'**/e2e-app/**',
17+
'**/universal-app/**',
18+
];
19+
20+
// Use glob + createSourceFile since we don't need type information
21+
// and this generally faster than creating a program.
22+
glob(fileToCheck, {absolute: true, ignore: ignoredPatterns})
23+
.map(path => ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true))
24+
.forEach(sourceFile => {
25+
sourceFile.statements.forEach(statement => {
26+
if (ts.isClassDeclaration(statement)) {
27+
checkClass(statement);
28+
}
29+
});
30+
});
31+
32+
if (errors.length) {
33+
console.error(chalk.red('Detected identical metadata between following components:'));
34+
errors.forEach(err => console.error(chalk.red(err)));
35+
console.error(
36+
chalk.red(
37+
`\nThe metadata of one of each of these components should be ` +
38+
`changed to be slightly different in order to avoid conflicts at runtime.\n`,
39+
),
40+
);
41+
42+
process.exit(1);
43+
}
44+
45+
/** Checks if a class will conflict with any of the classes that have been check so far. */
46+
function checkClass(node: ts.ClassDeclaration): void {
47+
const metadata = getComponentMetadata(node);
48+
49+
if (metadata) {
50+
// Create an ID for the component based on its metadata. This is based on what the framework
51+
// does at runtime at https://github.com/angular/angular/blob/main/packages/core/src/render3/definition.ts#L679.
52+
// Note that the behavior isn't exactly the same, because the framework uses some fields that
53+
// are generated by the compiler based on the component's template.
54+
const key = [
55+
serializeField('selector', metadata),
56+
serializeField('host', metadata),
57+
serializeField('encapsulation', metadata),
58+
serializeField('standalone', metadata),
59+
serializeField('signals', metadata),
60+
serializeField('exportAs', metadata),
61+
serializeBindings(node, metadata, 'inputs', 'Input'),
62+
serializeBindings(node, metadata, 'outputs', 'Output'),
63+
].join('|');
64+
65+
if (seenMetadata.has(key)) {
66+
errors.push(`- ${node.name?.text} and ${seenMetadata.get(key)!.name?.text}`);
67+
} else {
68+
seenMetadata.set(key, node);
69+
}
70+
}
71+
}
72+
73+
/** Serializes a field of an object literal node to a string. */
74+
function serializeField(name: string, metadata: ts.ObjectLiteralExpression): string {
75+
const prop = findPropAssignment(name, metadata);
76+
return prop ? serializeValue(prop.initializer).trim() : '<none>';
77+
}
78+
79+
/** Extracts the input/output bindings of a component and serializes them into a string. */
80+
function serializeBindings(
81+
node: ts.ClassDeclaration,
82+
metadata: ts.ObjectLiteralExpression,
83+
metaName: string,
84+
decoratorName: string,
85+
): string {
86+
const bindings: Record<string, string> = {};
87+
const metaProp = findPropAssignment(metaName, metadata);
88+
89+
if (metaProp && ts.isArrayLiteralExpression(metaProp.initializer)) {
90+
metaProp.initializer.elements.forEach(el => {
91+
if (ts.isStringLiteralLike(el)) {
92+
const [name, alias] = el.text.split(':').map(p => p.trim());
93+
bindings[alias || name] = name;
94+
} else if (ts.isObjectLiteralExpression(el)) {
95+
const name = findPropAssignment('name', el);
96+
const alias = findPropAssignment('alias', el);
97+
98+
if (name && ts.isStringLiteralLike(name.initializer)) {
99+
const publicName =
100+
alias && ts.isStringLiteralLike(alias.initializer)
101+
? alias.initializer.text
102+
: name.initializer.text;
103+
bindings[publicName] = name.initializer.text;
104+
}
105+
}
106+
});
107+
}
108+
109+
node.members.forEach(member => {
110+
if (!ts.isPropertyDeclaration(member) || !ts.isIdentifier(member.name)) {
111+
return;
112+
}
113+
114+
const decorator = findDecorator(decoratorName, member);
115+
116+
if (decorator) {
117+
const publicName =
118+
decorator.expression.arguments.length > 0 &&
119+
ts.isStringLiteralLike(decorator.expression.arguments[0])
120+
? decorator.expression.arguments[0].text
121+
: member.name.text;
122+
bindings[publicName] = member.name.text;
123+
}
124+
});
125+
126+
return JSON.stringify(bindings);
127+
}
128+
129+
/** Serializes a single value to a string. */
130+
function serializeValue(node: ts.Node): string {
131+
if (ts.isStringLiteralLike(node) || ts.isIdentifier(node)) {
132+
return node.text;
133+
}
134+
135+
if (ts.isArrayLiteralExpression(node)) {
136+
return JSON.stringify(node.elements.map(serializeValue));
137+
}
138+
139+
if (ts.isObjectLiteralExpression(node)) {
140+
const serialized = node.properties
141+
.slice()
142+
// Sort the fields since JS engines preserve the order properties in object literals.
143+
.sort((a, b) => (a.name?.getText() || '').localeCompare(b.name?.getText() || ''))
144+
.reduce((accumulator, prop) => {
145+
if (ts.isPropertyAssignment(prop)) {
146+
accumulator[prop.name.getText()] = serializeValue(prop.initializer);
147+
}
148+
149+
return accumulator;
150+
}, {} as Record<string, string>);
151+
152+
return JSON.stringify(serialized);
153+
}
154+
155+
return node.getText();
156+
}
157+
158+
/** Gets the object literal containing the Angular component metadata of a class. */
159+
function getComponentMetadata(node: ts.ClassDeclaration): ts.ObjectLiteralExpression | null {
160+
const decorator = findDecorator('Component', node);
161+
162+
if (!decorator) {
163+
return null;
164+
}
165+
166+
if (
167+
decorator.expression.arguments.length === 0 ||
168+
!ts.isObjectLiteralExpression(decorator.expression.arguments[0])
169+
) {
170+
throw new Error(
171+
`Cannot analyze class ${node.name?.text || 'Anonymous'} in ${node.getSourceFile().fileName}.`,
172+
);
173+
}
174+
175+
return decorator.expression.arguments[0];
176+
}
177+
178+
/** Finds a decorator with a specific name on a node. */
179+
function findDecorator(name: string, node: ts.HasDecorators) {
180+
return ts
181+
.getDecorators(node)
182+
?.find(
183+
current =>
184+
ts.isCallExpression(current.expression) &&
185+
ts.isIdentifier(current.expression.expression) &&
186+
current.expression.expression.text === name,
187+
) as (ts.Decorator & {expression: ts.CallExpression}) | undefined;
188+
}
189+
190+
/** Finds a specific property of an object literal node. */
191+
function findPropAssignment(
192+
name: string,
193+
literal: ts.ObjectLiteralExpression,
194+
): ts.PropertyAssignment | undefined {
195+
return literal.properties.find(
196+
current =>
197+
ts.isPropertyAssignment(current) &&
198+
ts.isIdentifier(current.name) &&
199+
current.name.text === name,
200+
) as ts.PropertyAssignment | undefined;
201+
}

0 commit comments

Comments
 (0)