From c2f2ec39cd1b13dadaee51352274cc6fdd50ca36 Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Thu, 11 Sep 2025 13:02:56 +0200 Subject: [PATCH] refactor(core): Add a schematic to update NgZone This migration goes along the breaking change introduced by #63673 when `EventEmitter`s props have been replaced `Subject`s --- packages/core/schematics/BUILD.bazel | 5 + packages/core/schematics/migrations.json | 6 + .../ngzone-event-emitter/BUILD.bazel | 22 +++ .../migrations/ngzone-event-emitter/index.ts | 20 +++ .../ngzone-event-emitter-migration.ts | 119 +++++++++++++++ .../test/ngzone_event_emitter_spec.ts | 135 ++++++++++++++++++ 6 files changed, 307 insertions(+) create mode 100644 packages/core/schematics/migrations/ngzone-event-emitter/BUILD.bazel create mode 100644 packages/core/schematics/migrations/ngzone-event-emitter/index.ts create mode 100644 packages/core/schematics/migrations/ngzone-event-emitter/ngzone-event-emitter-migration.ts create mode 100644 packages/core/schematics/test/ngzone_event_emitter_spec.ts diff --git a/packages/core/schematics/BUILD.bazel b/packages/core/schematics/BUILD.bazel index d91c2e9f38fc..d104fd13b2d9 100644 --- a/packages/core/schematics/BUILD.bazel +++ b/packages/core/schematics/BUILD.bazel @@ -118,6 +118,10 @@ bundle_entrypoints = [ "add-bootstrap-context-to-server-main", "packages/core/schematics/migrations/add-bootstrap-context-to-server-main/index.js", ], + [ + "ngzone-event-emitter", + "packages/core/schematics/migrations/ngzone-event-emitter/index.js", + ], ] rollup.rollup( @@ -133,6 +137,7 @@ rollup.rollup( "//packages/core/schematics/migrations/application-config-core", "//packages/core/schematics/migrations/control-flow-migration", "//packages/core/schematics/migrations/ngclass-to-class-migration", + "//packages/core/schematics/migrations/ngzone-event-emitter", "//packages/core/schematics/migrations/router-current-navigation", "//packages/core/schematics/migrations/router-last-successful-navigation", "//packages/core/schematics/ng-generate/cleanup-unused-imports", diff --git a/packages/core/schematics/migrations.json b/packages/core/schematics/migrations.json index 2872200af1d6..38502f2a5b80 100644 --- a/packages/core/schematics/migrations.json +++ b/packages/core/schematics/migrations.json @@ -26,6 +26,12 @@ "version": "21.0.0", "description": "Adds `BootstrapContext` to `bootstrapApplication` calls in `main.server.ts` to support server rendering.", "factory": "./bundles/add-bootstrap-context-to-server-main.cjs#migrate" + }, + "ngzone-event-emitter": { + "version": "21.0.0", + "description": "Replaces invocations on the NgZone EventEmitters with invocations on Subjects", + "factory": "./bundles/ngzone-event-emitter.cjs#migrate", + "optional": true } } } diff --git a/packages/core/schematics/migrations/ngzone-event-emitter/BUILD.bazel b/packages/core/schematics/migrations/ngzone-event-emitter/BUILD.bazel new file mode 100644 index 000000000000..47004efcfc8c --- /dev/null +++ b/packages/core/schematics/migrations/ngzone-event-emitter/BUILD.bazel @@ -0,0 +1,22 @@ +load("//tools:defaults.bzl", "ts_project") + +package( + default_visibility = [ + "//packages/core/schematics:__pkg__", + "//packages/core/schematics/test:__pkg__", + ], +) + +ts_project( + name = "ngzone-event-emitter", + srcs = glob(["**/*.ts"]), + deps = [ + "//:node_modules/@angular-devkit/schematics", + "//:node_modules/typescript", + "//packages/compiler-cli/private", + "//packages/compiler-cli/src/ngtsc/file_system", + "//packages/core/schematics/utils", + "//packages/core/schematics/utils/tsurge", + "//packages/core/schematics/utils/tsurge/helpers/angular_devkit", + ], +) diff --git a/packages/core/schematics/migrations/ngzone-event-emitter/index.ts b/packages/core/schematics/migrations/ngzone-event-emitter/index.ts new file mode 100644 index 000000000000..20bd90da40a3 --- /dev/null +++ b/packages/core/schematics/migrations/ngzone-event-emitter/index.ts @@ -0,0 +1,20 @@ +/*! + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Rule} from '@angular-devkit/schematics'; +import {NgZoneEventEmitterMigration} from './ngzone-event-emitter-migration'; +import {runMigrationInDevkit} from '../../utils/tsurge/helpers/angular_devkit'; + +export function migrate(): Rule { + return async (tree) => { + await runMigrationInDevkit({ + tree, + getMigration: () => new NgZoneEventEmitterMigration(), + }); + }; +} diff --git a/packages/core/schematics/migrations/ngzone-event-emitter/ngzone-event-emitter-migration.ts b/packages/core/schematics/migrations/ngzone-event-emitter/ngzone-event-emitter-migration.ts new file mode 100644 index 000000000000..ea74ca84fffe --- /dev/null +++ b/packages/core/schematics/migrations/ngzone-event-emitter/ngzone-event-emitter-migration.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + confirmAsSerializable, + ProgramInfo, + projectFile, + Replacement, + Serializable, + TextUpdate, + TsurgeFunnelMigration, +} from '../../utils/tsurge'; +import ts from 'typescript'; + +export interface CompilationUnitData { + replacements: Replacement[]; +} + +/** Migration that replaces invocations on EventEmitters with invocations on Subject on NgZone instances */ +export class NgZoneEventEmitterMigration extends TsurgeFunnelMigration< + CompilationUnitData, + CompilationUnitData +> { + override async analyze(info: ProgramInfo): Promise> { + const replacements: Replacement[] = []; + + for (const sourceFile of info.sourceFiles) { + const typeChecker = info.program.getTypeChecker(); + sourceFile.forEachChild(function walk(node) { + if (ts.isCallExpression(node)) { + const expression = node.expression; + + if (ts.isPropertyAccessExpression(expression) && expression.name.text === 'emit') { + const target = expression.expression; + const targetType = typeChecker.getTypeAtLocation(target); + const targetSymbol = targetType.getSymbol(); + + if (targetSymbol && targetSymbol?.name === 'EventEmitter') { + const ngZoneType = typeChecker.getTypeAtLocation( + (target as ts.PropertyAccessExpression).expression, + ); + if (isNgZoneOrSubclass(ngZoneType, typeChecker)) { + replacements.push({ + projectFile: projectFile(sourceFile, info), + update: new TextUpdate({ + position: expression.name.getStart(), + end: expression.name.getEnd(), + toInsert: `next`, + }), + }); + } + } + } + } + node.forEachChild(walk); + }); + } + + return confirmAsSerializable({replacements}); + } + + override async migrate(globalData: CompilationUnitData) { + return confirmAsSerializable(globalData); + } + + override async combine( + unitA: CompilationUnitData, + unitB: CompilationUnitData, + ): Promise> { + const seen = new Set(); + const combined: Replacement[] = []; + + [unitA.replacements, unitB.replacements].forEach((replacements) => { + replacements.forEach((current) => { + const {position, end, toInsert} = current.update.data; + const key = current.projectFile.id + '/' + position + '/' + end + '/' + toInsert; + + if (!seen.has(key)) { + seen.add(key); + combined.push(current); + } + }); + }); + + return confirmAsSerializable({replacements: combined}); + } + + override async globalMeta( + combinedData: CompilationUnitData, + ): Promise> { + return confirmAsSerializable(combinedData); + } + + override async stats() { + return confirmAsSerializable({}); + } +} + +function isNgZoneOrSubclass(type: ts.Type, typeChecker: ts.TypeChecker): boolean { + if (type.symbol?.name === 'NgZone') { + return true; + } + + if (type.symbol) { + const baseTypes = typeChecker.getDeclaredTypeOfSymbol(type.symbol)?.getBaseTypes() ?? []; + for (const baseType of baseTypes) { + if (isNgZoneOrSubclass(baseType, typeChecker)) { + return true; + } + } + } + + return false; +} diff --git a/packages/core/schematics/test/ngzone_event_emitter_spec.ts b/packages/core/schematics/test/ngzone_event_emitter_spec.ts new file mode 100644 index 000000000000..20b9406e9943 --- /dev/null +++ b/packages/core/schematics/test/ngzone_event_emitter_spec.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {getSystemPath, normalize, virtualFs} from '@angular-devkit/core'; +import {TempScopedNodeJsSyncHost} from '@angular-devkit/core/node/testing'; +import {HostTree} from '@angular-devkit/schematics'; +import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing/index.js'; +import {resolve} from 'node:path'; +import shx from 'shelljs'; + +describe('NgZone EventEventEmitter migration', () => { + let runner: SchematicTestRunner; + let host: TempScopedNodeJsSyncHost; + let tree: UnitTestTree; + let tmpDirPath: string; + + function writeFile(filePath: string, contents: string) { + host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents)); + } + + function runMigration() { + return runner.runSchematic('ngzone-event-emitter', {}, tree); + } + + const migrationsJsonPath = resolve('../migrations.json'); + beforeEach(() => { + runner = new SchematicTestRunner('test', migrationsJsonPath); + host = new TempScopedNodeJsSyncHost(); + tree = new UnitTestTree(new HostTree(host)); + tmpDirPath = getSystemPath(host.root); + + writeFile('/tsconfig.json', '{}'); + writeFile( + '/angular.json', + JSON.stringify({ + version: 1, + projects: {t: {root: '', architect: {build: {options: {tsConfig: './tsconfig.json'}}}}}, + }), + ); + + writeFile( + '/node_modules/@angular/core/index.d.ts', + ` + export declare class EventEmitter { + emit(value: any): void; + } + + export declare class NgZone { + onMicrotaskEmpty: EventEmitter; + onStable: EventEmitter; + onUnstable: EventEmitter; + onError: EventEmitter; + } + `, + ); + + shx.cd(tmpDirPath); + }); + + it('should migrate NgZone EventEmitter.emit() to Subject.next()', async () => { + writeFile( + '/dir.ts', + ` + import { NgZone } from '@angular/core'; + + class Test { + constructor(private ngZone: NgZone) {} + + test(eventEmitter: EventEmitter) { + this.ngZone.onMicrotaskEmpty.emit('test'); + this.ngZone.onStable.emit('test'); + this.ngZone.onUnstable.emit('test'); + this.ngZone.onError.emit(new Error('test')); + } + `, + ); + + await runMigration(); + const content = tree.readContent('/dir.ts'); + expect(content).toContain(`onMicrotaskEmpty.next('test');`); + expect(content).toContain(`onStable.next('test');`); + expect(content).toContain(`onUnstable.next('test');`); + expect(content).toContain(`onError.next(new Error('test'));`); + }); + + it('should not change other EventEmitter.emit() calls', async () => { + writeFile( + '/dir.ts', + ` + import { NgZone, EventEmitter } from '@angular/core'; + + class Test { + constructor(private ngZone: NgZone) {} + + test(eventEmitter: EventEmitter) { + eventEmitter.emit('test'); + } + `, + ); + + await runMigration(); + const content = tree.readContent('/dir.ts'); + expect(content).toContain(`eventEmitter.emit('test');`); + }); + + it('should change emit() calls in NgZone subclasses', async () => { + writeFile( + '/dir.ts', + ` + import { NgZone } from '@angular/core'; + + class MyZone extends NgZone { + test() { + this.onMicrotaskEmpty.emit('test'); + this.onStable.emit('test'); + this.onUnstable.emit('test'); + this.onError.emit(new Error('test')); + } + } + `, + ); + + await runMigration(); + const content = tree.readContent('/dir.ts'); + expect(content).toContain(`this.onMicrotaskEmpty.next('test');`); + expect(content).toContain(`this.onStable.next('test');`); + expect(content).toContain(`this.onUnstable.next('test');`); + expect(content).toContain(`this.onError.next(new Error('test'));`); + }); +});