diff --git a/packages/schematics/angular/migrations/migration-collection.json b/packages/schematics/angular/migrations/migration-collection.json index 077cb851907b..d7bf9151f72f 100644 --- a/packages/schematics/angular/migrations/migration-collection.json +++ b/packages/schematics/angular/migrations/migration-collection.json @@ -110,6 +110,11 @@ "factory": "./update-9/update-i18n#updateI18nConfig", "description": "Remove deprecated ViewEngine-based i18n build and extract options. Options present in the configuration will be converted to use non-deprecated options." }, + "update-web-workers-webpack-5": { + "version": "12.0.0-next.7", + "factory": "./update-12/update-web-workers", + "description": "Updates Web Worker consumer usage to use the new syntax supported directly by Webpack 5." + }, "production-by-default": { "version": "9999.0.0", "factory": "./update-12/production-default-config", diff --git a/packages/schematics/angular/migrations/update-12/update-web-workers.ts b/packages/schematics/angular/migrations/update-12/update-web-workers.ts new file mode 100644 index 000000000000..bd931801f5d1 --- /dev/null +++ b/packages/schematics/angular/migrations/update-12/update-web-workers.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright Google Inc. 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.io/license + */ +import { DirEntry, Rule, UpdateRecorder } from '@angular-devkit/schematics'; +import * as ts from '../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; + +function* visit(directory: DirEntry): IterableIterator { + for (const path of directory.subfiles) { + if (path.endsWith('.ts') && !path.endsWith('.d.ts')) { + const entry = directory.file(path); + if (entry) { + const content = entry.content; + if (content.includes('Worker')) { + const source = ts.createSourceFile( + entry.path, + // Remove UTF-8 BOM if present + // TypeScript expects the BOM to be stripped prior to parsing + content.toString().replace(/^\uFEFF/, ''), + ts.ScriptTarget.Latest, + true, + ); + + yield source; + } + } + } + } + + for (const path of directory.subdirs) { + if (path === 'node_modules' || path.startsWith('.')) { + continue; + } + + yield* visit(directory.dir(path)); + } +} + +function hasPropertyWithValue(node: ts.Expression, name: string, value: unknown): boolean { + if (!ts.isObjectLiteralExpression(node)) { + return false; + } + + for (const property of node.properties) { + if (!ts.isPropertyAssignment(property)) { + continue; + } + if (!ts.isIdentifier(property.name) || property.name.text !== 'type') { + continue; + } + if (ts.isStringLiteralLike(property.initializer)) { + return property.initializer.text === 'module'; + } + } + + return false; +} + +export default function (): Rule { + return (tree) => { + for (const sourceFile of visit(tree.root)) { + let recorder: UpdateRecorder | undefined; + + ts.forEachChild(sourceFile, function analyze(node) { + // Only modify code in the form of `new Worker('./app.worker', { type: 'module' })`. + // `worker-plugin` required the second argument to be an object literal with type=module + if ( + ts.isNewExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'Worker' && + node.arguments?.length === 2 && + ts.isStringLiteralLike(node.arguments[0]) && + hasPropertyWithValue(node.arguments[1], 'type', 'module') + ) { + const valueNode = node.arguments[0] as ts.StringLiteralLike; + + // Webpack expects a URL constructor: https://webpack.js.org/guides/web-workers/ + const fix = `new URL('${valueNode.text}', import.meta.url)`; + + if (!recorder) { + recorder = tree.beginUpdate(sourceFile.fileName); + } + + const index = valueNode.getStart(); + const length = valueNode.getWidth(); + recorder.remove(index, length).insertLeft(index, fix); + } + + ts.forEachChild(node, analyze); + }); + + if (recorder) { + tree.commitUpdate(recorder); + } + } + }; +} diff --git a/packages/schematics/angular/migrations/update-12/update-web-workers_spec.ts b/packages/schematics/angular/migrations/update-12/update-web-workers_spec.ts new file mode 100644 index 000000000000..57fa139a857f --- /dev/null +++ b/packages/schematics/angular/migrations/update-12/update-web-workers_spec.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright Google Inc. 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.io/license + */ +import { EmptyTree } from '@angular-devkit/schematics'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; + +describe('Migration to update Web Workers for Webpack 5', () => { + const schematicRunner = new SchematicTestRunner( + 'migrations', + require.resolve('../migration-collection.json'), + ); + + let tree: UnitTestTree; + + const workerConsumerPath = 'src/consumer.ts'; + const workerConsumerContent = ` + import { enableProdMode } from '@angular/core'; + import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + import { AppModule } from './app/app.module'; + import { environment } from './environments/environment'; + if (environment.production) { enableProdMode(); } + platformBrowserDynamic().bootstrapModule(AppModule).catch(err => console.error(err)); + + const worker = new Worker('./app/app.worker', { type: 'module' }); + worker.onmessage = ({ data }) => { + console.log('page got message:', data); + }; + worker.postMessage('hello'); + `; + + beforeEach(async () => { + tree = new UnitTestTree(new EmptyTree()); + tree.create('/package.json', JSON.stringify({})); + }); + + it('should replace the string path argument with a URL constructor', async () => { + tree.create(workerConsumerPath, workerConsumerContent); + + await schematicRunner.runSchematicAsync('update-web-workers-webpack-5', {}, tree).toPromise(); + await schematicRunner.engine.executePostTasks().toPromise(); + + const consumer = tree.readContent(workerConsumerPath); + + expect(consumer).not.toContain(`new Worker('./app/app.worker'`); + expect(consumer).toContain( + `new Worker(new URL('./app/app.worker', import.meta.url), { type: 'module' });`, + ); + }); + + it('should not replace the first argument if arguments types are invalid', async () => { + tree.create(workerConsumerPath, workerConsumerContent.replace(`'./app/app.worker'`, '42')); + + await schematicRunner.runSchematicAsync('update-web-workers-webpack-5', {}, tree).toPromise(); + await schematicRunner.engine.executePostTasks().toPromise(); + + const consumer = tree.readContent(workerConsumerPath); + + expect(consumer).toContain(`new Worker(42`); + expect(consumer).not.toContain( + `new Worker(new URL('42', import.meta.url), { type: 'module' });`, + ); + }); + + it('should not replace the first argument if type value is not "module"', async () => { + tree.create(workerConsumerPath, workerConsumerContent.replace(`type: 'module'`, `type: 'xyz'`)); + + await schematicRunner.runSchematicAsync('update-web-workers-webpack-5', {}, tree).toPromise(); + await schematicRunner.engine.executePostTasks().toPromise(); + + const consumer = tree.readContent(workerConsumerPath); + + expect(consumer).toContain(`new Worker('./app/app.worker'`); + expect(consumer).not.toContain( + `new Worker(new URL('42', import.meta.url), { type: 'xyz' });`, + ); + }); + + it('should replace the module path string when file has BOM', async () => { + tree.create(workerConsumerPath, '\uFEFF' + workerConsumerContent); + + await schematicRunner.runSchematicAsync('update-web-workers-webpack-5', {}, tree).toPromise(); + await schematicRunner.engine.executePostTasks().toPromise(); + + const consumer = tree.readContent(workerConsumerPath); + + expect(consumer).not.toContain(`new Worker('./app/app.worker'`); + expect(consumer).toContain( + `new Worker(new URL('./app/app.worker', import.meta.url), { type: 'module' });`, + ); + }); +});