UNPKG

@angular/core

Version:

Angular - the core framework

582 lines (577 loc) 26.9 kB
'use strict'; /** * @license Angular v22.0.0 * (c) 2010-2026 Google LLC. https://angular.dev/ * License: MIT */ 'use strict'; require('@angular-devkit/core'); require('node:path/posix'); var project_paths = require('./project_paths-D2V-Uh2L.cjs'); var compiler = require('@angular/compiler'); var ts = require('typescript'); var ng_component_template = require('./ng_component_template-DPAF1aEA.cjs'); var ng_decorators = require('./ng_decorators-IVztR9rk.cjs'); require('@angular/compiler-cli'); require('@angular/compiler-cli/private/migrations'); require('node:path'); var property_name = require('./property_name-BCpALNpZ.cjs'); require('@angular-devkit/schematics'); require('./project_tsconfig_paths-DkkMibv-.cjs'); require('./imports-CKV-ITqD.cjs'); const SAFE_NAVIGATION_MIGRATION_FN = '$safeNavigationMigration'; /** * This migration wraps optional chaining expressions in Angular templates with a call to the * `$safeNavigationMigration()` magic function. This function doesn't exist at runtime, but is * used as a marker for the Angular compiler to transform the expression to keep the legacy * behavior of returning `null`. * * The migration uses a top-down "sink" approach: each expression is visited with a boolean * `nullSensitive` context that indicates whether the consumer of the expression's value * distinguishes between `null` and `undefined`. Safe navigation operators (`?.`) wrap themselves * when their sink is null-sensitive. */ class SafeOptionalChainingMigration extends project_paths.TsurgeFunnelMigration { config; constructor(config = {}) { super(); this.config = config; } async analyze(info) { const replacements = []; // Template Iteration const templateVisitor = new ng_component_template.NgComponentTemplateVisitor(info.program.getTypeChecker()); for (const sourceFile of info.sourceFiles) { templateVisitor.visitNode(sourceFile); } for (const template of templateVisitor.resolvedTemplates) { const nodes = compiler.parseTemplate(template.content, template.filePath.toString(), { preserveWhitespaces: true, preserveLineEndings: true, preserveSignificantWhitespace: true, leadingTriviaChars: [], }).nodes; const file = template.inline ? project_paths.projectFile(template.container.getSourceFile(), info) : project_paths.projectFile(template.filePath, info); const exprMigrator = new ExpressionMigrator(file, template.start); const visitor = new TmplVisitor(exprMigrator); for (const node of nodes) { if (node.visit) { node.visit(visitor); } } replacements.push(...exprMigrator.replacements); } for (const sourceFile of info.sourceFiles) { replacements.push(...migrateHostBindingsInSourceFile(sourceFile, info)); } return project_paths.confirmAsSerializable({ replacements }); } async combine(unitA, unitB) { const seen = new Set(); const deduped = []; for (const r of [...unitA.replacements, ...unitB.replacements]) { const key = `${r.projectFile.rootRelativePath}:${r.update.data.position}:${r.update.data.end}:${r.update.data.toInsert}`; if (!seen.has(key)) { seen.add(key); deduped.push(r); } } return project_paths.confirmAsSerializable({ replacements: deduped }); } async globalMeta(data) { return project_paths.confirmAsSerializable(data); } async stats(data) { return project_paths.confirmAsSerializable({}); } async migrate(data) { return { replacements: data.replacements }; } } function migrateHostBindingsInSourceFile(sourceFile, info) { const replacements = []; const file = project_paths.projectFile(sourceFile, info); const typeChecker = info.program.getTypeChecker(); const visitNode = (node) => { if (ts.isClassDeclaration(node)) { const decorators = ts.getDecorators(node) ?? []; const ngDecorators = ng_decorators.getAngularDecorators(typeChecker, decorators); for (const decorator of ngDecorators) { if (decorator.name !== 'Component' && decorator.name !== 'Directive') { continue; } const metadata = decorator.node.expression.arguments[0]; if (!metadata || !ts.isObjectLiteralExpression(metadata)) { continue; } for (const prop of metadata.properties) { if (!ts.isPropertyAssignment(prop)) { continue; } const propName = property_name.getPropertyNameText(prop.name); if (propName !== 'host' || !ts.isObjectLiteralExpression(prop.initializer)) { continue; } for (const hostProp of prop.initializer.properties) { if (!ts.isPropertyAssignment(hostProp) || !ts.isStringLiteralLike(hostProp.initializer)) { continue; } const hostKey = property_name.getPropertyNameText(hostProp.name); if (hostKey === null || (!hostKey.startsWith('[') && !hostKey.startsWith('('))) { continue; } // Preserve raw text between quotes/backticks so source offsets stay aligned. const hostExpression = hostProp.initializer.getText().slice(1, -1); const fakeTemplatePrefix = `<div ${hostKey}="`; const fakeTemplate = `${fakeTemplatePrefix}${hostExpression}"></div>`; const parsedNodes = compiler.parseTemplate(fakeTemplate, sourceFile.fileName, { preserveWhitespaces: true, }).nodes; const hostExpressionStart = hostProp.initializer.getStart() + 1; const exprMigrator = new ExpressionMigrator(file, hostExpressionStart - fakeTemplatePrefix.length); const visitor = new HostBindingVisitor(exprMigrator); for (const parsedNode of parsedNodes) { if (parsedNode.visit) { parsedNode.visit(visitor); } } replacements.push(...exprMigrator.replacements); } } } } ts.forEachChild(node, visitNode); }; visitNode(sourceFile); return replacements; } /** Returns whether the given attribute is a class, style, or attribute binding. */ function isClassStyleOrAttrBinding(attribute) { return (attribute.type === compiler.BindingType.Class || attribute.type === compiler.BindingType.Style || attribute.type === compiler.BindingType.Attribute || (attribute.type === compiler.BindingType.Property && (attribute.name === 'class' || attribute.name === 'className' || attribute.name === 'style'))); } class HostBindingVisitor extends compiler.TmplAstRecursiveVisitor { hostExprMigrator; constructor(hostExprMigrator) { super(); this.hostExprMigrator = hostExprMigrator; } visitBoundAttribute(attribute) { if (isClassStyleOrAttrBinding(attribute)) { // Class/style/attr bindings use truthiness — not null-sensitive by default. // Safe navs inside function calls or pipes will still be wrapped by the migrator. attribute.value.visit(this.hostExprMigrator, false); } else { // Regular property bindings are null-sensitive. attribute.value.visit(this.hostExprMigrator, true); } super.visitBoundAttribute(attribute); } visitBoundEvent(event) { if (event.handler && event.handler.visit) { // Event handlers are not null-sensitive; safe navs inside function calls will // still be wrapped because function arguments are always null-sensitive. event.handler.visit(this.hostExprMigrator, false); } super.visitBoundEvent(event); } } /** * Visits template expressions and inserts `$safeNavigationMigration(…)` wrappers around safe * navigation chains whose result is consumed by a null-sensitive sink. * * The `context` parameter at every visit call is a boolean `nullSensitive` flag that answers: * "does the parent care whether this expression's value is `null` vs. `undefined`?" * * Propagation rules: * - `||`, `&&`, `??`: children are **not** null-sensitive (both normalise null/undefined). * - `===` / `!==` with a nullish literal on one side: the other operand **is** null-sensitive. * - All other binary operators: propagate the parent's null-sensitivity. * - `!` (prefix not): operand is **not** null-sensitive (uses truthiness). * - Ternary condition: **not** null-sensitive; branches inherit the parent's null-sensitivity. * - Function call arguments and pipe inputs: **always** null-sensitive. * - Receivers of property/keyed/call reads: **not** null-sensitive by default. * For property/keyed/call continuations, when the receiver is a safe node and the parent sink * is null-sensitive, wrap the full continuation node (e.g. `foo?.bar.baz`, `foo?.save()`) rather * than the inner safe receiver (`foo?.bar`, `foo?.save`) so runtime behavior is preserved. * - `NonNullAssert` (`!`): propagates the parent's null-sensitivity (type-only assertion). */ class ExpressionMigrator extends compiler.RecursiveAstVisitor { file; templateStart; replacements = []; constructor(file, templateStart) { super(); this.file = file; this.templateStart = templateStart; } // --------------------------------------------------------------------------- // Safe navigation nodes — wrap when the sink is null-sensitive // --------------------------------------------------------------------------- visitSafePropertyRead(ast, nullSensitive) { if (nullSensitive) { this.addReplacement(ast); } // Receiver: not null-sensitive (further access on null/undefined throws either way). this.visit(ast.receiver, false); } visitSafeKeyedRead(ast, nullSensitive) { if (nullSensitive) { this.addReplacement(ast); } this.visit(ast.receiver, false); this.visit(ast.key, false); } visitSafeCall(ast, nullSensitive) { if (nullSensitive) { this.addReplacement(ast); } this.visit(ast.receiver, false); this.visitAll(ast.args, true); } hasSafeReceiver(receiver) { if (receiver instanceof compiler.SafePropertyRead || receiver instanceof compiler.SafeKeyedRead || receiver instanceof compiler.SafeCall) { return true; } if (receiver instanceof compiler.NonNullAssert) { return this.hasSafeReceiver(receiver.expression); } return false; } // --------------------------------------------------------------------------- // Non-safe access — receiver is never null-sensitive // --------------------------------------------------------------------------- visitPropertyRead(ast, nullSensitive) { if (nullSensitive && this.hasSafeReceiver(ast.receiver)) { this.addReplacement(ast); } this.visit(ast.receiver, false); } visitKeyedRead(ast, nullSensitive) { if (nullSensitive && this.hasSafeReceiver(ast.receiver)) { this.addReplacement(ast); } this.visit(ast.receiver, false); this.visit(ast.key, false); } // --------------------------------------------------------------------------- // Function calls — arguments are always null-sensitive // --------------------------------------------------------------------------- visitCall(ast, nullSensitive) { if (isSafeNavigationMigrationCall(ast)) { this.visit(ast.receiver, false); return; } if (nullSensitive && this.hasSafeReceiver(ast.receiver)) { this.addReplacement(ast); } this.visit(ast.receiver, false); this.visitAll(ast.args, true); } // --------------------------------------------------------------------------- // Pipes — input and arguments are always null-sensitive // --------------------------------------------------------------------------- visitPipe(ast, _nullSensitive) { this.visit(ast.exp, true); this.visitAll(ast.args, true); } // --------------------------------------------------------------------------- // Binary operators // --------------------------------------------------------------------------- visitBinary(ast, nullSensitive) { if (ast.operation === '||' || ast.operation === '&&' || ast.operation === '??') { // These operators normalise null and undefined (both produce the same result), // so the operands are not null-sensitive. this.visit(ast.left, false); this.visit(ast.right, false); } else if (ast.operation === '===' || ast.operation === '!==') { // A strict comparison with a nullish literal makes the other side null-sensitive // (null === null is true but undefined === null is false). const leftIsNullish = isNullishLiteralAST(ast.left); const rightIsNullish = isNullishLiteralAST(ast.right); this.visit(ast.left, rightIsNullish); this.visit(ast.right, leftIsNullish); } else { // All other binary operators (<, >, +, -, …): propagate the parent's null-sensitivity // because null and undefined can produce different numeric results (null → 0, // undefined → NaN for arithmetic/comparison). this.visit(ast.left, nullSensitive); this.visit(ast.right, nullSensitive); } } // --------------------------------------------------------------------------- // Unary / logical operators // --------------------------------------------------------------------------- visitPrefixNot(ast, _nullSensitive) { // Logical negation uses truthiness — null and undefined are both falsy. this.visit(ast.expression, false); } // --------------------------------------------------------------------------- // Ternary // --------------------------------------------------------------------------- visitConditional(ast, nullSensitive) { // The condition is evaluated as a boolean — not null-sensitive. this.visit(ast.condition, false); // The result of a branch is consumed by the parent, so it inherits null-sensitivity. this.visit(ast.trueExp, nullSensitive); this.visit(ast.falseExp, nullSensitive); } // --------------------------------------------------------------------------- // NonNullAssert — a compile-time type annotation, no runtime effect // --------------------------------------------------------------------------- visitNonNullAssert(ast, nullSensitive) { this.visit(ast.expression, nullSensitive); } // --------------------------------------------------------------------------- // Chain (event handler statements) — never null-sensitive // --------------------------------------------------------------------------- visitChain(ast, _nullSensitive) { this.visitAll(ast.expressions, false); } // --------------------------------------------------------------------------- // Interpolation — coerces to string, so null and undefined produce identical // output; sub-expressions are therefore never null-sensitive. // --------------------------------------------------------------------------- visitInterpolation(ast, _nullSensitive) { this.visitAll(ast.expressions, false); } // --------------------------------------------------------------------------- // All other nodes (LiteralArray, LiteralMap, Unary, …) use the default // RecursiveAstVisitor which propagates the current context to every child — // the correct "inherit parent null-sensitivity" fallback. // --------------------------------------------------------------------------- addReplacement(ast) { const startArg = ast.sourceSpan.start; const endArg = ast.sourceSpan.end; this.replacements.push(new project_paths.Replacement(this.file, new project_paths.TextUpdate({ position: this.templateStart + endArg, end: this.templateStart + endArg, toInsert: ')', })), new project_paths.Replacement(this.file, new project_paths.TextUpdate({ position: this.templateStart + startArg, end: this.templateStart + startArg, toInsert: '$safeNavigationMigration(', }))); } } // --------------------------------------------------------------------------- // Helper utilities // --------------------------------------------------------------------------- /** Returns true if the AST node is a literal `null` or `undefined`. */ function isNullishLiteralAST(ast) { const innerAst = ast instanceof compiler.ASTWithSource ? ast.ast : ast; return (innerAst instanceof compiler.LiteralPrimitive && (innerAst.value === null || innerAst.value === undefined)); } function isSafeNavigationMigrationCall(ast) { const innerAst = ast instanceof compiler.ASTWithSource ? ast.ast : ast; return (innerAst instanceof compiler.Call && innerAst.receiver instanceof compiler.PropertyRead && innerAst.receiver.name === SAFE_NAVIGATION_MIGRATION_FN); } /** Returns true if the AST node is a non-null, non-undefined primitive literal. */ function isNonNullishLiteralAST(ast) { const innerAst = ast instanceof compiler.ASTWithSource ? ast.ast : ast; return (innerAst instanceof compiler.LiteralPrimitive && innerAst.value !== null && innerAst.value !== undefined); } // --------------------------------------------------------------------------- // Template visitor // --------------------------------------------------------------------------- /** * Returns true if all *ngSwitchCase bindings in the given nodes (and their children) * are non-null/non-undefined literal expressions — meaning the switch expression * doesn't need null-sensitivity migration. */ function allNgSwitchCasesAreLiterals(nodes) { for (const node of nodes) { for (const input of node.inputs) { if (input.name === 'ngSwitchCase' && input.value) { if (!isNonNullishLiteralAST(input.value)) { return false; } } } if (node instanceof compiler.TmplAstTemplate) { for (const attr of node.templateAttrs) { if (attr instanceof compiler.TmplAstBoundAttribute && attr.name === 'ngSwitchCase' && attr.value) { if (!isNonNullishLiteralAST(attr.value)) { return false; } } } } const childHosts = node.children.filter((child) => child instanceof compiler.TmplAstElement || child instanceof compiler.TmplAstTemplate); if (!allNgSwitchCasesAreLiterals(childHosts)) { return false; } } return true; } class TmplVisitor extends compiler.TmplAstRecursiveVisitor { exprMigrator; migratableSwitchCases = new WeakSet(); /** * Stack tracking whether the current ngSwitch context should be migrated. * False when all *ngSwitchCase expressions are non-null literals. */ ngSwitchShouldMigrateStack = []; constructor(exprMigrator) { super(); this.exprMigrator = exprMigrator; } shouldMigrateCurrentNgSwitchContext() { return this.ngSwitchShouldMigrateStack[this.ngSwitchShouldMigrateStack.length - 1] ?? true; } hasNgSwitchBinding(node) { return (node.inputs.some((attr) => attr.name === 'ngSwitch') || (node instanceof compiler.TmplAstTemplate && node.templateAttrs.some((attr) => attr.name === 'ngSwitch'))); } visitElement(element) { const hasNgSwitch = this.hasNgSwitchBinding(element); if (hasNgSwitch) { const childHosts = element.children.filter((child) => child instanceof compiler.TmplAstElement || child instanceof compiler.TmplAstTemplate); this.ngSwitchShouldMigrateStack.push(!allNgSwitchCasesAreLiterals(childHosts)); } super.visitElement(element); if (hasNgSwitch) { this.ngSwitchShouldMigrateStack.pop(); } } visitBoundAttribute(attribute) { if (attribute.name === 'ngForOf') { // ngFor/@for item expressions are not null-sensitive by default. // Still visit so inner null-sensitive sinks (e.g. pipes/functions) are migrated. attribute.value.visit(this.exprMigrator, false); } else if (attribute.name === 'ngIf') { // ngIf evaluates as a boolean; null-sensitivity only arises when the expression // itself contains a strict null comparison (handled inside ExpressionMigrator). attribute.value.visit(this.exprMigrator, false); } else if (attribute.name === 'ngSwitch' || attribute.name === 'ngSwitchCase') { attribute.value.visit(this.exprMigrator, this.shouldMigrateCurrentNgSwitchContext()); } else if (isClassStyleOrAttrBinding(attribute)) { // Class/style/attr bindings use truthiness — not null-sensitive by default. attribute.value.visit(this.exprMigrator, false); } else { // Regular property bindings are null-sensitive. attribute.value.visit(this.exprMigrator, true); } super.visitBoundAttribute(attribute); } visitBoundEvent(event) { if (event.handler && event.handler.visit) { // Event handlers are not null-sensitive. Safe navs inside function calls // (e.g. `compute(user?.save())`) are still migrated because function arguments // are always null-sensitive in ExpressionMigrator. event.handler.visit(this.exprMigrator, false); } super.visitBoundEvent(event); } visitBoundText(text) { // Interpolation text is not null-sensitive by default; null-sensitivity is // introduced by pipes, function calls, or strict null comparisons inside // the expression, all handled by ExpressionMigrator. text.value.visit(this.exprMigrator, false); super.visitBoundText(text); } visitTemplate(template) { const hasNgSwitch = this.hasNgSwitchBinding(template); if (hasNgSwitch) { const childHosts = template.children.filter((child) => child instanceof compiler.TmplAstElement || child instanceof compiler.TmplAstTemplate); this.ngSwitchShouldMigrateStack.push(!allNgSwitchCasesAreLiterals(childHosts)); } for (const attr of template.templateAttrs) { if (!(attr instanceof compiler.TmplAstBoundAttribute)) { continue; } if (attr.name === 'ngIf') { attr.value.visit(this.exprMigrator, false); } else if (attr.name === 'ngSwitch' || attr.name === 'ngSwitchCase') { attr.value.visit(this.exprMigrator, this.shouldMigrateCurrentNgSwitchContext()); } else if (attr.name === 'ngForOf') { // ngFor microsyntax expressions are not null-sensitive by default. // Still visit so nested null-sensitive sinks are handled. attr.value.visit(this.exprMigrator, false); } else { attr.value.visit(this.exprMigrator, true); } } super.visitTemplate(template); if (hasNgSwitch) { this.ngSwitchShouldMigrateStack.pop(); } } visitIfBlockBranch(block) { if (block.expression) { // @if condition: not null-sensitive by default (same logic as ngIf). block.expression.visit(this.exprMigrator, false); } super.visitIfBlockBranch(block); } visitForLoopBlock(block) { block.expression.visit(this.exprMigrator, false); block.trackBy?.visit(this.exprMigrator, false); super.visitForLoopBlock(block); } visitLetDeclaration(decl) { // @let value is assigned directly — null-sensitive. decl.value.visit(this.exprMigrator, true); super.visitLetDeclaration(decl); } visitSwitchBlock(block) { const switchCases = block.groups.flatMap((group) => group.cases); // Don't migrate if every case expression is a non-null/non-undefined literal // (e.g. strings, numbers, booleans). In that case null-vs-undefined can never // match a case, so wrapping the switch expression would be pointless. const shouldMigrate = !switchCases .filter((switchCase) => switchCase.expression) .every((switchCase) => isNonNullishLiteralAST(switchCase.expression)); if (shouldMigrate) { block.expression.visit(this.exprMigrator, true); for (const switchCase of switchCases) { this.migratableSwitchCases.add(switchCase); } } super.visitSwitchBlock(block); } visitSwitchBlockCase(block) { if (this.migratableSwitchCases.has(block) && block.expression) { block.expression.visit(this.exprMigrator, true); } super.visitSwitchBlockCase(block); } visitDeferredTrigger(trigger) { if (trigger instanceof compiler.TmplAstBoundDeferredTrigger) { // @defer (when …): same logic as @if — not null-sensitive by default. trigger.value.visit(this.exprMigrator, false); } super.visitDeferredTrigger(trigger); } } function migrate() { return async (tree) => { await project_paths.runMigrationInDevkit({ tree, getMigration: () => new SafeOptionalChainingMigration(), }); }; } exports.migrate = migrate;