Skip to content

Commit

Permalink
build: add a script to detect component ID collisions (angular#27411)
Browse files Browse the repository at this point in the history
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 angular#27163.
  • Loading branch information
crisbeto authored and stephenrca committed Aug 2, 2023
1 parent cd9b594 commit 735e7f9
Show file tree
Hide file tree
Showing 2 changed files with 202 additions and 0 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"format": "yarn -s ng-dev format changed",
"cherry-pick-patch": "ts-node --project tools/cherry-pick-patch/tsconfig.json tools/cherry-pick-patch/cherry-pick-patch.ts",
"ownerslint": "ts-node --project scripts/tsconfig.json scripts/ownerslint.ts",
"detect-component-id-collisions": "ts-node --project scripts/tsconfig.json scripts/detect-component-id-collisions.ts",
"tslint": "tslint -c tslint.json --project ./tsconfig.json",
"stylelint": "stylelint \"src/**/*.+(css|scss)\" --config .stylelintrc.json",
"resync-caretaker-app": "ts-node --project scripts/tsconfig.json scripts/caretaking/resync-caretaker-app-prs.ts",
Expand Down
201 changes: 201 additions & 0 deletions scripts/detect-component-id-collisions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import ts from 'typescript';
import chalk from 'chalk';
import {readFileSync} from 'fs';
import {join} from 'path';
import {sync as glob} from 'glob';

// This script aims to detect if the unique IDs of two components may collide at runtime
// in order to avoid issues like https://github.com/angular/components/issues/27163.

const errors: string[] = [];
const seenMetadata = new Map<string, ts.ClassDeclaration>();
const fileToCheck = join(__dirname, '../src/**/!(*.spec).ts');
const ignoredPatterns = [
'**/components-examples/**',
'**/dev-app/**',
'**/e2e-app/**',
'**/universal-app/**',
];

// Use glob + createSourceFile since we don't need type information
// and this generally faster than creating a program.
glob(fileToCheck, {absolute: true, ignore: ignoredPatterns})
.map(path => ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true))
.forEach(sourceFile => {
sourceFile.statements.forEach(statement => {
if (ts.isClassDeclaration(statement)) {
validateClass(statement);
}
});
});

if (errors.length) {
console.error(chalk.red('Detected identical metadata between following components:'));
errors.forEach(err => console.error(chalk.red(err)));
console.error(
chalk.red(
`\nThe metadata of one of each of these components should be ` +
`changed to be slightly different in order to avoid conflicts at runtime.\n`,
),
);

process.exit(1);
}

/** Validates if a class will conflict with any of the classes that have been checked so far. */
function validateClass(node: ts.ClassDeclaration): void {
const metadata = getComponentMetadata(node);

if (metadata) {
// Create an ID for the component based on its metadata. This is based on what the framework
// does at runtime at https://github.com/angular/angular/blob/main/packages/core/src/render3/definition.ts#L679.
// Note that the behavior isn't exactly the same, because the framework uses some fields that
// are generated by the compiler based on the component's template.
const key = [
serializeField('selector', metadata),
serializeField('host', metadata),
serializeField('encapsulation', metadata),
serializeField('standalone', metadata),
serializeField('signals', metadata),
serializeField('exportAs', metadata),
serializeBindings(node, metadata, 'inputs', 'Input'),
serializeBindings(node, metadata, 'outputs', 'Output'),
].join('|');

if (seenMetadata.has(key)) {
errors.push(`- ${node.name?.text} and ${seenMetadata.get(key)!.name?.text}`);
} else {
seenMetadata.set(key, node);
}
}
}

/** Serializes a field of an object literal node to a string. */
function serializeField(name: string, metadata: ts.ObjectLiteralExpression): string {
const prop = findPropAssignment(name, metadata);
return prop ? serializeValue(prop.initializer).trim() : '<none>';
}

/** Extracts the input/output bindings of a component and serializes them into a string. */
function serializeBindings(
node: ts.ClassDeclaration,
metadata: ts.ObjectLiteralExpression,
metaName: string,
decoratorName: string,
): string {
const bindings: Record<string, string> = {};
const metaProp = findPropAssignment(metaName, metadata);

if (metaProp && ts.isArrayLiteralExpression(metaProp.initializer)) {
metaProp.initializer.elements.forEach(el => {
if (ts.isStringLiteralLike(el)) {
const [name, alias] = el.text.split(':').map(p => p.trim());
bindings[alias || name] = name;
} else if (ts.isObjectLiteralExpression(el)) {
const name = findPropAssignment('name', el);
const alias = findPropAssignment('alias', el);

if (name && ts.isStringLiteralLike(name.initializer)) {
const publicName =
alias && ts.isStringLiteralLike(alias.initializer)
? alias.initializer.text
: name.initializer.text;
bindings[publicName] = name.initializer.text;
}
}
});
}

node.members.forEach(member => {
if (!ts.isPropertyDeclaration(member) || !ts.isIdentifier(member.name)) {
return;
}

const decorator = findDecorator(decoratorName, member);

if (decorator) {
const publicName =
decorator.expression.arguments.length > 0 &&
ts.isStringLiteralLike(decorator.expression.arguments[0])
? decorator.expression.arguments[0].text
: member.name.text;
bindings[publicName] = member.name.text;
}
});

return JSON.stringify(bindings);
}

/** Serializes a single value to a string. */
function serializeValue(node: ts.Node): string {
if (ts.isStringLiteralLike(node) || ts.isIdentifier(node)) {
return node.text;
}

if (ts.isArrayLiteralExpression(node)) {
return JSON.stringify(node.elements.map(serializeValue));
}

if (ts.isObjectLiteralExpression(node)) {
const serialized = node.properties
.slice()
// Sort the fields since JS engines preserve the order properties in object literals.
.sort((a, b) => (a.name?.getText() || '').localeCompare(b.name?.getText() || ''))
.reduce((accumulator, prop) => {
if (ts.isPropertyAssignment(prop)) {
accumulator[prop.name.getText()] = serializeValue(prop.initializer);
}

return accumulator;
}, {} as Record<string, string>);

return JSON.stringify(serialized);
}

return node.getText();
}

/** Gets the object literal containing the Angular component metadata of a class. */
function getComponentMetadata(node: ts.ClassDeclaration): ts.ObjectLiteralExpression | null {
const decorator = findDecorator('Component', node);

if (!decorator) {
return null;
}

if (
decorator.expression.arguments.length === 0 ||
!ts.isObjectLiteralExpression(decorator.expression.arguments[0])
) {
throw new Error(
`Cannot analyze class ${node.name?.text || 'Anonymous'} in ${node.getSourceFile().fileName}.`,
);
}

return decorator.expression.arguments[0];
}

/** Finds a decorator with a specific name on a node. */
function findDecorator(name: string, node: ts.HasDecorators) {
return ts
.getDecorators(node)
?.find(
current =>
ts.isCallExpression(current.expression) &&
ts.isIdentifier(current.expression.expression) &&
current.expression.expression.text === name,
) as (ts.Decorator & {expression: ts.CallExpression}) | undefined;
}

/** Finds a specific property of an object literal node. */
function findPropAssignment(
name: string,
literal: ts.ObjectLiteralExpression,
): ts.PropertyAssignment | undefined {
return literal.properties.find(
current =>
ts.isPropertyAssignment(current) &&
ts.isIdentifier(current.name) &&
current.name.text === name,
) as ts.PropertyAssignment | undefined;
}

0 comments on commit 735e7f9

Please sign in to comment.