From 39b7be8588658eb13f05f33f2b5c00c28d8afc33 Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Wed, 21 Aug 2024 01:05:01 +0200 Subject: [PATCH 01/41] refactor(compiler-cli): Add support for inheritance in API extraction (#57588) This commit adds the `extends` and `implements` properties to the `ClassEntry` & `InterfaceEntry` PR Close #57588 --- .../src/ngtsc/docs/src/class_extractor.ts | 32 +++++++++++++++++++ .../src/ngtsc/docs/src/entities.ts | 2 ++ .../class_doc_extraction_spec.ts | 21 ++++++++++++ 3 files changed, 55 insertions(+) diff --git a/packages/compiler-cli/src/ngtsc/docs/src/class_extractor.ts b/packages/compiler-cli/src/ngtsc/docs/src/class_extractor.ts index 9e461e4f7fa5a..d39b0f765aa4c 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/class_extractor.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/class_extractor.ts @@ -85,6 +85,8 @@ class ClassExtractor { description: extractJsDocDescription(this.declaration), jsdocTags: extractJsDocTags(this.declaration), rawComment: extractRawJsDoc(this.declaration), + extends: this.extractInheritance(this.declaration), + implements: this.extractInterfaceConformance(this.declaration), }; } @@ -175,6 +177,36 @@ class ClassExtractor { }; } + protected extractInheritance( + declaration: ClassDeclaration & ClassDeclarationLike, + ): string | undefined { + if (!declaration.heritageClauses) { + return undefined; + } + + for (const clause of declaration.heritageClauses) { + if (clause.token === ts.SyntaxKind.ExtendsKeyword) { + // We are assuming a single class can only extend one class. + const types = clause.types; + if (types.length > 0) { + const baseClass: ts.ExpressionWithTypeArguments = types[0]; + return baseClass.getText(); + } + } + } + + return undefined; + } + protected extractInterfaceConformance( + declaration: ClassDeclaration & ClassDeclarationLike, + ): string[] { + const implementClause = declaration.heritageClauses?.find( + (clause) => clause.token === ts.SyntaxKind.ImplementsKeyword, + ); + + return implementClause?.types.map((m) => m.getText()) ?? []; + } + /** Gets the tags for a member (protected, readonly, static, etc.) */ protected getMemberTags(member: MethodLike | PropertyLike): MemberTags[] { const tags: MemberTags[] = this.getMemberTagsFromModifiers(member.modifiers ?? []); diff --git a/packages/compiler-cli/src/ngtsc/docs/src/entities.ts b/packages/compiler-cli/src/ngtsc/docs/src/entities.ts index f3c6378374a68..c93b5a94d606b 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/entities.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/entities.ts @@ -107,6 +107,8 @@ export interface ClassEntry extends DocEntry { isAbstract: boolean; members: MemberEntry[]; generics: GenericEntry[]; + extends?: string; + implements: string[]; } // From an API doc perspective, class and interfaces are identical. diff --git a/packages/compiler-cli/test/ngtsc/doc_extraction/class_doc_extraction_spec.ts b/packages/compiler-cli/test/ngtsc/doc_extraction/class_doc_extraction_spec.ts index 606bc1acdd946..6d8a2d8d119d1 100644 --- a/packages/compiler-cli/test/ngtsc/doc_extraction/class_doc_extraction_spec.ts +++ b/packages/compiler-cli/test/ngtsc/doc_extraction/class_doc_extraction_spec.ts @@ -461,6 +461,27 @@ runInEachFileSystem(() => { expect(genericEntry.default).toBeUndefined(); }); + it('should extract inheritence/interface conformance', () => { + env.write( + 'index.ts', + ` + interface Foo {} + interface Bar {} + + class Parent extends Ancestor {} + + export class Child extends Parent implements Foo, Bar {} + `, + ); + + const docs: DocEntry[] = env.driveDocsExtraction('index.ts'); + expect(docs.length).toBe(1); + + const classEntry = docs[0] as ClassEntry; + expect(classEntry.extends).toBe('Parent'); + expect(classEntry.implements).toEqual(['Foo', 'Bar']); + }); + it('should extract inherited members', () => { env.write( 'index.ts', From a84cecfc99907390f4530d85145e7361a37c1dc6 Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Wed, 21 Aug 2024 01:23:10 +0200 Subject: [PATCH 02/41] docs(docs-infra): add support for `extends`/`implements` on API entries (#57588) PR Close #57588 --- .../pipeline/api-gen/rendering/entities.ts | 2 ++ .../rendering/transforms/code-transforms.ts | 25 ++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/adev/shared-docs/pipeline/api-gen/rendering/entities.ts b/adev/shared-docs/pipeline/api-gen/rendering/entities.ts index 7b7de01a5bb64..3f616713ebfd5 100644 --- a/adev/shared-docs/pipeline/api-gen/rendering/entities.ts +++ b/adev/shared-docs/pipeline/api-gen/rendering/entities.ts @@ -101,6 +101,8 @@ export interface ClassEntry extends DocEntry { isAbstract: boolean; members: MemberEntry[]; generics: GenericEntry[]; + extends?: string; + implements: string[]; } // From an API doc perspective, class and interfaces are identical. diff --git a/adev/shared-docs/pipeline/api-gen/rendering/transforms/code-transforms.ts b/adev/shared-docs/pipeline/api-gen/rendering/transforms/code-transforms.ts index b77227963ffb7..e8dde55a86357 100644 --- a/adev/shared-docs/pipeline/api-gen/rendering/transforms/code-transforms.ts +++ b/adev/shared-docs/pipeline/api-gen/rendering/transforms/code-transforms.ts @@ -426,9 +426,28 @@ function appendPrefixAndSuffix(entry: DocEntry, codeTocData: CodeTableOfContents data.contents = `${firstLine}\n${data.contents}${lastLine}`; }; - if (isClassEntry(entry)) { - const abstractPrefix = entry.isAbstract ? 'abstract ' : ''; - appendFirstAndLastLines(codeTocData, `${abstractPrefix}class ${entry.name} {`, `}`); + if (isClassEntry(entry) || isInterfaceEntry(entry)) { + const generics = + entry.generics?.length > 0 + ? `<${entry.generics + .map((g) => (g.constraint ? `${g.name} extends ${g.constraint}` : g.name)) + .join(', ')}>` + : ''; + + const extendsStr = entry.extends ? ` extends ${entry.extends}` : ''; + // TODO: remove the ? when we distinguish Class & Decorator entries + const implementsStr = + entry.implements?.length > 0 ? ` implements ${entry.implements.join(' ,')}` : ''; + + const signature = `${entry.name}${generics}${extendsStr}${implementsStr}`; + if (isClassEntry(entry)) { + const abstractPrefix = entry.isAbstract ? 'abstract ' : ''; + appendFirstAndLastLines(codeTocData, `${abstractPrefix}class ${signature} {`, `}`); + } + + if (isInterfaceEntry(entry)) { + appendFirstAndLastLines(codeTocData, `interface ${signature} {`, `}`); + } } if (isEnumEntry(entry)) { From 7defbb164267f1a1997f648a605e3a086ef071e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfredo=20Gonz=C3=A1lez?= Date: Mon, 2 Sep 2024 09:30:37 -0400 Subject: [PATCH 03/41] docs(upgrade): change example wording on Input tutorial (#57625) PR Close #57625 --- .../src/content/tutorials/learn-angular/steps/8-input/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adev/src/content/tutorials/learn-angular/steps/8-input/README.md b/adev/src/content/tutorials/learn-angular/steps/8-input/README.md index 2b11ce81980b8..8ff5431a9aab9 100644 --- a/adev/src/content/tutorials/learn-angular/steps/8-input/README.md +++ b/adev/src/content/tutorials/learn-angular/steps/8-input/README.md @@ -31,7 +31,7 @@ Make sure you bind the property `occupation` in your `UserComponent`. @Component({ ... - template: `

The user's name is {{occupation}}

` + template: `

The user's occupation is {{occupation}}

` })
From fc1734441b11864aff851a5afc7b31624889e53c Mon Sep 17 00:00:00 2001 From: Angular Robot Date: Mon, 2 Sep 2024 20:05:06 +0000 Subject: [PATCH 04/41] build: update dependency diff to v6 (#57631) See associated pull request for more information. PR Close #57631 --- adev/shared-docs/package.json | 2 +- package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/adev/shared-docs/package.json b/adev/shared-docs/package.json index a8686dc6ac445..04c71fe3ca018 100644 --- a/adev/shared-docs/package.json +++ b/adev/shared-docs/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@webcontainer/api": "^1.1.8", - "diff": "~5.2.0", + "diff": "~6.0.0", "emoji-regex": "~10.4.0", "fast-glob": "~3.3.2", "fflate": "^0.8.2", diff --git a/package.json b/package.json index df86c1734676f..f638d540a48ba 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "chokidar": "^3.5.1", "convert-source-map": "^1.5.1", "d3": "^7.0.0", - "diff": "^5.0.0", + "diff": "^6.0.0", "domino": "https://github.com/angular/domino.git#8f228f8862540c6ccd14f76b5a1d9bb5458618af", "hammerjs": "~2.0.8", "http-server": "^14.0.0", diff --git a/yarn.lock b/yarn.lock index 14bfa34a5f759..d8aa6a21b3950 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8645,10 +8645,10 @@ diff@^4.0.1: resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== -diff@^5.0.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" - integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== +diff@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-6.0.0.tgz#eb5931d25d073c41eb39285aa45f2970d01c6c7a" + integrity sha512-NbGtgPSw7il+jeajji1H6iKjCk3r/ANQKw3FFUhGV50+MH5MKIMeUmi53piTr7jlkWcq9eS858qbkRzkehwe+w== dir-glob@^3.0.1: version "3.0.1" From 4c82eb23cb4bcbababdd9fe38998deaedaee5224 Mon Sep 17 00:00:00 2001 From: Angular Robot Date: Tue, 3 Sep 2024 13:07:34 +0000 Subject: [PATCH 05/41] build: update all non-major dependencies (#57633) See associated pull request for more information. PR Close #57633 --- .github/workflows/pr.yml | 2 +- package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8c74a72a9d242..0c5a10a41d538 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -95,7 +95,7 @@ jobs: - name: Run CI tests for framework run: yarn tsx ./scripts/build/build-packages-dist.mts - name: Archive build artifacts - uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 + uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 with: name: pr-artifacts-${{ github.event.number }} path: dist/packages-dist/ diff --git a/package.json b/package.json index f638d540a48ba..b2f68ed8aa73b 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "@types/selenium-webdriver4": "npm:@types/selenium-webdriver@4.1.26", "@types/semver": "^7.3.4", "@types/shelljs": "^0.8.6", - "@types/systemjs": "6.13.5", + "@types/systemjs": "6.15.0", "@types/yargs": "^17.0.3", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", diff --git a/yarn.lock b/yarn.lock index d8aa6a21b3950..e2a7c0832de20 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5339,10 +5339,10 @@ resolved "https://registry.yarnpkg.com/@types/supports-color/-/supports-color-8.1.3.tgz#b769cdce1d1bb1a3fa794e35b62c62acdf93c139" integrity sha512-Hy6UMpxhE3j1tLpl27exp1XqHD7n8chAiNPzWfz16LPZoMMoSc4dzLl6w9qijkEb/r5O1ozdu1CWGA2L83ZeZg== -"@types/systemjs@6.13.5": - version "6.13.5" - resolved "https://registry.yarnpkg.com/@types/systemjs/-/systemjs-6.13.5.tgz#dcc763ddf50558cea14019f2c17fc3b704ef0141" - integrity sha512-VWG7Z1/cb90UQF3HjkVcE+PB2kts93mW/94XQ2XUyHk+4wpzVrTdfXw0xeoaVyI/2XUuBRuCA7Is25RhEfHXNg== +"@types/systemjs@6.15.0": + version "6.15.0" + resolved "https://registry.yarnpkg.com/@types/systemjs/-/systemjs-6.15.0.tgz#098ea50650bd128550e9564e2c818cde17d22aee" + integrity sha512-cnlGl3wretbgmtNOo43OtIUctcAeXddy+vrBFp4Pz2spuEfsTS/sol0KsUG1qmP1OeU4SgbRYPKxNIlkkXdUBQ== "@types/tmp@^0.2.1": version "0.2.6" From 5a2faed13db38cf07f2b28599c71c18d767a007d Mon Sep 17 00:00:00 2001 From: Angular Robot Date: Tue, 3 Sep 2024 06:04:02 +0000 Subject: [PATCH 06/41] build: update scorecard action dependencies (#57636) See associated pull request for more information. PR Close #57636 --- .github/workflows/scorecard.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index fdedc6c44a1d7..59309bf2e9d25 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -39,7 +39,7 @@ jobs: # Upload the results as artifacts. - name: 'Upload artifact' - uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 + uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 with: name: SARIF file path: results.sarif @@ -47,6 +47,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: 'Upload to code-scanning' - uses: github/codeql-action/upload-sarif@2c779ab0d087cd7fe7b826087247c2c81f27bfa6 # v3.26.5 + uses: github/codeql-action/upload-sarif@4dd16135b69a43b6c8efb853346f8437d92d3c93 # v3.26.6 with: sarif_file: results.sarif From 5ce9abfd630bfd3e59d94f3c515990ab35f9580b Mon Sep 17 00:00:00 2001 From: Angular Robot Date: Tue, 3 Sep 2024 05:03:51 +0000 Subject: [PATCH 07/41] build: update babel dependencies to v7.25.6 (#57634) See associated pull request for more information. PR Close #57634 --- package.json | 4 ++-- yarn.lock | 30 +++++++++++++++--------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index b2f68ed8aa73b..89488486e7e38 100644 --- a/package.json +++ b/package.json @@ -55,9 +55,9 @@ "@angular/cdk": "18.1.0", "@angular/cli": "18.1.0-next.1", "@angular/material": "18.1.0", - "@babel/cli": "7.24.8", + "@babel/cli": "7.25.6", "@babel/core": "7.25.2", - "@babel/generator": "7.25.5", + "@babel/generator": "7.25.6", "@bazel/concatjs": "5.8.1", "@bazel/esbuild": "5.8.1", "@bazel/jasmine": "5.8.1", diff --git a/yarn.lock b/yarn.lock index e2a7c0832de20..cb4d3261d03e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -515,10 +515,10 @@ call-me-maybe "^1.0.1" js-yaml "^4.1.0" -"@babel/cli@7.24.8": - version "7.24.8" - resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.24.8.tgz#79eaa55a69c77cafbea3e87537fd1df5a5a2edf8" - integrity sha512-isdp+G6DpRyKc+3Gqxy2rjzgF7Zj9K0mzLNnxz+E/fgeag8qT3vVulX4gY9dGO1q0y+0lUv6V3a+uhUzMzrwXg== +"@babel/cli@7.25.6": + version "7.25.6" + resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.25.6.tgz#bc35561adc78ade43ac9c09a690768493ab9ed95" + integrity sha512-Z+Doemr4VtvSD2SNHTrkiFZ1LX+JI6tyRXAAOb4N9khIuPyoEPmTPJarPm8ljJV1D6bnMQjyHMWTT9NeKbQuXA== dependencies: "@jridgewell/trace-mapping" "^0.3.25" commander "^6.2.0" @@ -529,7 +529,7 @@ slash "^2.0.0" optionalDependencies: "@nicolo-ribaudo/chokidar-2" "2.1.8-no-fsevents.3" - chokidar "^3.4.0" + chokidar "^3.6.0" "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.22.13", "@babel/code-frame@^7.24.7": version "7.24.7" @@ -596,12 +596,12 @@ "@jridgewell/trace-mapping" "^0.3.25" jsesc "^2.5.1" -"@babel/generator@7.25.5": - version "7.25.5" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.25.5.tgz#b31cf05b3fe8c32d206b6dad03bb0aacbde73450" - integrity sha512-abd43wyLfbWoxC6ahM8xTkqLpGB2iWBVyuKC9/srhFunCd1SDNrV1s72bBpK4hLj8KLzHBBcOblvLQZBNw9r3w== +"@babel/generator@7.25.6": + version "7.25.6" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.25.6.tgz#0df1ad8cb32fe4d2b01d8bf437f153d19342a87c" + integrity sha512-VPC82gr1seXOpkjAAKoLhP50vx4vGNlF4msF64dSFq1P8RfB+QAuJWGHPXXPc8QyfVWwwB/TNNU4+ayZmHNbZw== dependencies: - "@babel/types" "^7.25.4" + "@babel/types" "^7.25.6" "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.25" jsesc "^2.5.1" @@ -1537,10 +1537,10 @@ "@babel/helper-validator-identifier" "^7.24.7" to-fast-properties "^2.0.0" -"@babel/types@^7.25.4": - version "7.25.4" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.25.4.tgz#6bcb46c72fdf1012a209d016c07f769e10adcb5f" - integrity sha512-zQ1ijeeCXVEh+aNL0RlmkPkG8HUiDcU2pzQQFjtbntgAczRASFzj4H+6+bV+dy1ntKR14I/DypeuRG1uma98iQ== +"@babel/types@^7.25.6": + version "7.25.6" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.25.6.tgz#893942ddb858f32ae7a004ec9d3a76b3463ef8e6" + integrity sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw== dependencies: "@babel/helper-string-parser" "^7.24.8" "@babel/helper-validator-identifier" "^7.24.7" @@ -7033,7 +7033,7 @@ chevrotain@~11.0.3: "@chevrotain/utils" "11.0.3" lodash-es "4.17.21" -"chokidar@>=3.0.0 <4.0.0", chokidar@^3.0.0, chokidar@^3.4.0, chokidar@^3.5.1, chokidar@^3.5.3, chokidar@^3.6.0: +"chokidar@>=3.0.0 <4.0.0", chokidar@^3.0.0, chokidar@^3.5.1, chokidar@^3.5.3, chokidar@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== From a65e87457edf4f0e5a897cabe5c6d6990f22ca45 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Mon, 2 Sep 2024 12:12:49 +0000 Subject: [PATCH 08/41] refactor(migrations): add explicit test for non-null assertions (#57629) Adds an explicit test to state the situation about non-null assertions and that we don't remove the exclamation marks here for now. PR Close #57629 --- .../src/convert-input/prepare_and_check.ts | 2 ++ .../test/golden-test/non_null_assertions.ts | 15 +++++++++++++++ .../migrations/signal-migration/test/golden.txt | 17 +++++++++++++++++ .../test/golden_best_effort.txt | 17 +++++++++++++++++ 4 files changed, 51 insertions(+) create mode 100644 packages/core/schematics/migrations/signal-migration/test/golden-test/non_null_assertions.ts diff --git a/packages/core/schematics/migrations/signal-migration/src/convert-input/prepare_and_check.ts b/packages/core/schematics/migrations/signal-migration/src/convert-input/prepare_and_check.ts index 11af2f0da6874..266d7255988af 100644 --- a/packages/core/schematics/migrations/signal-migration/src/convert-input/prepare_and_check.ts +++ b/packages/core/schematics/migrations/signal-migration/src/convert-input/prepare_and_check.ts @@ -23,6 +23,7 @@ import assert from 'assert'; export interface ConvertInputPreparation { resolvedType: ts.TypeNode | undefined; preferShorthandIfPossible: {originalType: ts.TypeNode} | null; + requiredButIncludedUndefinedPreviously: boolean; resolvedMetadata: ExtractedInput; originalInputDecorator: Decorator; initialValue: ts.Expression | undefined; @@ -123,6 +124,7 @@ export function prepareAndCheckForConversion( } return { + requiredButIncludedUndefinedPreviously: metadata.required && node.questionToken !== undefined, resolvedMetadata: metadata, resolvedType: typeToAdd, preferShorthandIfPossible, diff --git a/packages/core/schematics/migrations/signal-migration/test/golden-test/non_null_assertions.ts b/packages/core/schematics/migrations/signal-migration/test/golden-test/non_null_assertions.ts new file mode 100644 index 0000000000000..9a0dfefdf2809 --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/test/golden-test/non_null_assertions.ts @@ -0,0 +1,15 @@ +// tslint:disable + +import {Input, Directive} from '@angular/core'; + +@Directive() +export class NonNullAssertions { + // We can't remove `undefined` from the type here. It's unclear + // whether it was just used as a workaround for required inputs, or + // it was actually meant to be part of the type. + @Input({required: true}) name?: string; + + click() { + this.name!.charAt(0); + } +} diff --git a/packages/core/schematics/migrations/signal-migration/test/golden.txt b/packages/core/schematics/migrations/signal-migration/test/golden.txt index 5965f33e2fe6e..11e06015b7e85 100644 --- a/packages/core/schematics/migrations/signal-migration/test/golden.txt +++ b/packages/core/schematics/migrations/signal-migration/test/golden.txt @@ -713,6 +713,23 @@ interface Config { export class NestedTemplatePropAccess { readonly config = input({}); } +@@@@@@ non_null_assertions.ts @@@@@@ + +// tslint:disable + +import { Directive, input } from '@angular/core'; + +@Directive() +export class NonNullAssertions { + // We can't remove `undefined` from the type here. It's unclear + // whether it was just used as a workaround for required inputs, or + // it was actually meant to be part of the type. + readonly name = input.required(); + + click() { + this.name()!.charAt(0); + } +} @@@@@@ object_expansion.ts @@@@@@ // tslint:disable diff --git a/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt b/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt index 547a749425a1e..b6c385277a205 100644 --- a/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt +++ b/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt @@ -713,6 +713,23 @@ interface Config { export class NestedTemplatePropAccess { readonly config = input({}); } +@@@@@@ non_null_assertions.ts @@@@@@ + +// tslint:disable + +import { Directive, input } from '@angular/core'; + +@Directive() +export class NonNullAssertions { + // We can't remove `undefined` from the type here. It's unclear + // whether it was just used as a workaround for required inputs, or + // it was actually meant to be part of the type. + readonly name = input.required(); + + click() { + this.name()!.charAt(0); + } +} @@@@@@ object_expansion.ts @@@@@@ // tslint:disable From a8e3ba95504912e8b21ad23c782f0ed87b1abd36 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Mon, 2 Sep 2024 14:56:57 +0000 Subject: [PATCH 09/41] refactor(migrations): properly handle cases of `--strict=false` in signal input migration (#57629) Sometimes `--strictPropertyInitialization` is not enabled, while strict null checks is enabled. In those cases, `undefined` cannot be used as initial value with `input()`, nor can we expand the type of the input. We can migrate those instances to `undefined!` to preserve the original semantics and behavior. In addition, in the future we may leave a TODO or we may consider skipping migration of such inputs. PR Close #57629 --- .../signal-migration/src/BUILD.bazel | 1 + .../src/convert-input/prepare_and_check.ts | 38 +++++++- .../src/passes/1_identify_inputs.ts | 7 +- .../signal-migration/test/BUILD.bazel | 19 +++- .../signal-migration/test/migration_spec.ts | 94 +++++++++++++++++++ .../schematics/utils/tsurge/testing/index.ts | 4 + 6 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 packages/core/schematics/migrations/signal-migration/test/migration_spec.ts diff --git a/packages/core/schematics/migrations/signal-migration/src/BUILD.bazel b/packages/core/schematics/migrations/signal-migration/src/BUILD.bazel index 43f06da29d2b0..a1b62b832b535 100644 --- a/packages/core/schematics/migrations/signal-migration/src/BUILD.bazel +++ b/packages/core/schematics/migrations/signal-migration/src/BUILD.bazel @@ -7,6 +7,7 @@ ts_library( exclude = ["test/**"], ), visibility = [ + "//packages/core/schematics/migrations/signal-migration/test:__pkg__", "//packages/core/schematics/migrations/signal-queries-migration:__pkg__", "//packages/language-service:__subpackages__", ], diff --git a/packages/core/schematics/migrations/signal-migration/src/convert-input/prepare_and_check.ts b/packages/core/schematics/migrations/signal-migration/src/convert-input/prepare_and_check.ts index 266d7255988af..3e28a3872f18e 100644 --- a/packages/core/schematics/migrations/signal-migration/src/convert-input/prepare_and_check.ts +++ b/packages/core/schematics/migrations/signal-migration/src/convert-input/prepare_and_check.ts @@ -15,6 +15,7 @@ import { import {InputNode} from '../input_detection/input_node'; import {Decorator} from '@angular/compiler-cli/src/ngtsc/reflection'; import assert from 'assert'; +import {NgCompilerOptions} from '@angular/compiler-cli/src/ngtsc/core/api'; /** * Interface describing analysis performed when the input @@ -42,6 +43,7 @@ export function prepareAndCheckForConversion( node: InputNode, metadata: ExtractedInput, checker: ts.TypeChecker, + options: NgCompilerOptions, ): InputMemberIncompatibility | ConvertInputPreparation { // Accessor inputs cannot be migrated right now. if (ts.isAccessor(node)) { @@ -56,7 +58,13 @@ export function prepareAndCheckForConversion( 'Expected an input decorator for inputs that are being migrated.', ); - const initialValue = node.initializer; + let initialValue = node.initializer; + let isUndefinedInitialValue = + node.initializer === undefined || + (ts.isIdentifier(node.initializer) && node.initializer.text === 'undefined'); + + const loosePropertyInitializationWithStrictNullChecks = + options.strict !== true && options.strictPropertyInitialization !== true; // If an input can be required, due to the non-null assertion on the property, // make it required if there is no initializer. @@ -64,16 +72,18 @@ export function prepareAndCheckForConversion( metadata.required = true; } - const isUndefinedInitialValue = - node.initializer === undefined || - (ts.isIdentifier(node.initializer) && node.initializer.text === 'undefined'); let typeToAdd: ts.TypeNode | undefined = node.type; let preferShorthandIfPossible: {originalType: ts.TypeNode} | null = null; // If there is no initial value, or it's `undefined`, we can prefer the `input()` // shorthand which automatically uses `undefined` as initial value, and includes it // in the input type. - if (!metadata.required && node.type !== undefined && isUndefinedInitialValue) { + if ( + !metadata.required && + node.type !== undefined && + isUndefinedInitialValue && + !loosePropertyInitializationWithStrictNullChecks + ) { preferShorthandIfPossible = {originalType: node.type}; } @@ -91,6 +101,24 @@ export function prepareAndCheckForConversion( ]); } + // If the input does not have an initial value, and strict property initialization + // is disabled, while strict null checks are enabled; then we know that `undefined` + // cannot be used as initial value, nor do we want to expand the input's type magically. + // Instead, we detect this case and migrate to `undefined!` which leaves the behavior unchanged. + // TODO: This would be a good spot for a clean-up TODO. + if ( + loosePropertyInitializationWithStrictNullChecks && + node.initializer === undefined && + node.type !== undefined && + node.questionToken === undefined && + node.exclamationToken === undefined && + metadata.required === false && + !checker.isTypeAssignableTo(checker.getUndefinedType(), checker.getTypeFromTypeNode(node.type)) + ) { + isUndefinedInitialValue = false; + initialValue = ts.factory.createNonNullExpression(ts.factory.createIdentifier('undefined')); + } + // Attempt to extract type from input initial value. No explicit type, but input is required. // Hence we need an explicit type, or fall back to `typeof`. if (typeToAdd === undefined && initialValue !== undefined && metadata.required) { diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/1_identify_inputs.ts b/packages/core/schematics/migrations/signal-migration/src/passes/1_identify_inputs.ts index 0427c254a1acc..a96c2d88165f4 100644 --- a/packages/core/schematics/migrations/signal-migration/src/passes/1_identify_inputs.ts +++ b/packages/core/schematics/migrations/signal-migration/src/passes/1_identify_inputs.ts @@ -55,7 +55,12 @@ export function pass1__IdentifySourceFileAndDeclarationInputs( // track source file inputs in the result of this target. // these are then later migrated in the migration phase. if (decoratorInput.inSourceFile && host.isSourceFileForCurrentMigration(sf)) { - const conversionPreparation = prepareAndCheckForConversion(node, decoratorInput, checker); + const conversionPreparation = prepareAndCheckForConversion( + node, + decoratorInput, + checker, + host.options, + ); if (isInputMemberIncompatibility(conversionPreparation)) { knownDecoratorInputs.markInputAsIncompatible(inputDescr, conversionPreparation); diff --git a/packages/core/schematics/migrations/signal-migration/test/BUILD.bazel b/packages/core/schematics/migrations/signal-migration/test/BUILD.bazel index 5d13afc3d17e8..41ea5786ec5ad 100644 --- a/packages/core/schematics/migrations/signal-migration/test/BUILD.bazel +++ b/packages/core/schematics/migrations/signal-migration/test/BUILD.bazel @@ -1,6 +1,6 @@ load("@npm//@angular/build-tooling/bazel/integration:index.bzl", "integration_test") load("//packages/core/schematics/migrations/signal-migration/test/ts-versions:index.bzl", "TS_VERSIONS") -load("//tools:defaults.bzl", "nodejs_binary", "ts_library") +load("//tools:defaults.bzl", "jasmine_node_test", "nodejs_binary", "ts_library") ts_library( name = "test_runners_lib", @@ -126,3 +126,20 @@ integration_test( "//packages/core/schematics/migrations/signal-migration/src:batch_test_bin": "migration", }, ) + +ts_library( + name = "test_lib", + testonly = True, + srcs = ["migration_spec.ts"], + deps = [ + "//packages/compiler-cli/src/ngtsc/file_system", + "//packages/compiler-cli/src/ngtsc/file_system/testing", + "//packages/core/schematics/migrations/signal-migration/src", + "//packages/core/schematics/utils/tsurge", + ], +) + +jasmine_node_test( + name = "test_jasmine", + srcs = [":test_lib"], +) diff --git a/packages/core/schematics/migrations/signal-migration/test/migration_spec.ts b/packages/core/schematics/migrations/signal-migration/test/migration_spec.ts new file mode 100644 index 0000000000000..1b5aa93039875 --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/test/migration_spec.ts @@ -0,0 +1,94 @@ +/** + * @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.io/license + */ + +import {absoluteFrom} from '@angular/compiler-cli/src/ngtsc/file_system'; +import {initMockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; +import {runTsurgeMigration} from '../../../utils/tsurge/testing'; +import {SignalInputMigration} from '../src/migration'; + +describe('signal input migration', () => { + beforeEach(() => { + initMockFileSystem('Native'); + }); + + it( + 'should properly handle declarations with loose property initialization ' + + 'and strict null checks enabled', + async () => { + const fs = await runTsurgeMigration( + new SignalInputMigration(), + [ + { + name: absoluteFrom('/app.component.ts'), + isProgramRootFile: true, + contents: ` + import {Component, Input} from '@angular/core'; + + @Component({template: ''}) + class AppComponent { + @Input() name: string; + + doSmth() { + this.name.charAt(0); + } + } + `, + }, + ], + { + strict: false, + strictNullChecks: true, + }, + ); + + expect(fs.readFile(absoluteFrom('/app.component.ts'))).toContain( + 'readonly name = input(undefined!);', + ); + expect(fs.readFile(absoluteFrom('/app.component.ts'))).toContain('this.name().charAt(0);'); + }, + ); + + it( + 'should properly handle declarations with loose property initialization ' + + 'and strict null checks disabled', + async () => { + const fs = await runTsurgeMigration( + new SignalInputMigration(), + [ + { + name: absoluteFrom('/app.component.ts'), + isProgramRootFile: true, + contents: ` + import {Component, Input} from '@angular/core'; + + @Component({template: ''}) + class AppComponent { + @Input() name: string; + + doSmth() { + this.name.charAt(0); + } + } + `, + }, + ], + { + strict: false, + }, + ); + + expect(fs.readFile(absoluteFrom('/app.component.ts'))).toContain( + // Shorthand not used here to keep behavior unchanged, and to not + // risk expanding the type. In practice `string|undefined` would be + // fine though as long as the consumers also have strict null checks disabled. + 'readonly name = input(undefined);', + ); + expect(fs.readFile(absoluteFrom('/app.component.ts'))).toContain('this.name().charAt(0);'); + }, + ); +}); diff --git a/packages/core/schematics/utils/tsurge/testing/index.ts b/packages/core/schematics/utils/tsurge/testing/index.ts index bd49df1111449..f27e432aa642f 100644 --- a/packages/core/schematics/utils/tsurge/testing/index.ts +++ b/packages/core/schematics/utils/tsurge/testing/index.ts @@ -15,6 +15,7 @@ import { } from '@angular/compiler-cli/src/ngtsc/file_system'; import {groupReplacementsByFile} from '../helpers/group_replacements'; import {applyTextUpdates} from '../replacement'; +import ts from 'typescript'; /** * Runs the given migration against a fake set of files, emulating @@ -30,6 +31,7 @@ import {applyTextUpdates} from '../replacement'; export async function runTsurgeMigration( migration: TsurgeMigration, files: {name: AbsoluteFsPath; contents: string; isProgramRootFile?: boolean}[], + compilerOptions: ts.CompilerOptions = {}, ): Promise { const mockFs = getFileSystem(); if (!(mockFs instanceof MockFileSystem)) { @@ -47,7 +49,9 @@ export async function runTsurgeMigration( absoluteFrom('/tsconfig.json'), JSON.stringify({ compilerOptions: { + strict: true, rootDir: '/', + ...compilerOptions, }, files: rootFiles, }), From 2679dcfd444a6a7d67df4322b0792f57243428e0 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Fri, 30 Aug 2024 14:26:39 +0000 Subject: [PATCH 10/41] refactor(migrations): simplify signal input migration language-service integration (#57606) Instead of some special hook that relies on mutation to filter inputs in the signal input migration, we are now introducing a new configuration interface where the language-service can pass a filter method. This makes the code more readable. We also need the filter method to support filtering based on directories. E.g. when the migration runs against sub-folders, all inputs outside of the folder should be considered incompatible; to not migrate incorrectly. PR Close #57606 --- .../signal-migration/src/best_effort_mode.ts | 19 ++- .../migrations/signal-migration/src/index.ts | 9 +- .../src/input_detection/directive_info.ts | 20 ++-- .../src/input_detection/incompatibility.ts | 12 +- .../signal-migration/src/migration.ts | 113 ++++++++++++++---- .../passes/9_migrate_ts_type_references.ts | 4 +- .../signal-migration/src/phase_analysis.ts | 1 - .../test/golden_best_effort.txt | 8 +- .../refactorings/convert_to_signal_input.ts | 79 ++++-------- 9 files changed, 152 insertions(+), 113 deletions(-) diff --git a/packages/core/schematics/migrations/signal-migration/src/best_effort_mode.ts b/packages/core/schematics/migrations/signal-migration/src/best_effort_mode.ts index a14b3ebc210bb..fac1a79d2c380 100644 --- a/packages/core/schematics/migrations/signal-migration/src/best_effort_mode.ts +++ b/packages/core/schematics/migrations/signal-migration/src/best_effort_mode.ts @@ -6,18 +6,25 @@ * found in the LICENSE file at https://angular.io/license */ -import {nonIgnorableIncompatibilities} from './input_detection/incompatibility'; +import {InputIncompatibilityReason} from './input_detection/incompatibility'; import {KnownInputs} from './input_detection/known_inputs'; +/** Input reasons that cannot be ignored. */ +const nonIgnorableInputIncompatibilities: InputIncompatibilityReason[] = [ + // There is no good output for accessor inputs. + InputIncompatibilityReason.Accessor, + // There is no good output for such inputs. We can't perform "conversion". + InputIncompatibilityReason.RequiredInputButNoGoodExplicitTypeExtractable, +]; + /** Filters ignorable input incompatibilities when best effort mode is enabled. */ export function filterIncompatibilitiesForBestEffortMode(knownInputs: KnownInputs) { - // Remove all "ignorable" incompatibilities of inputs, if best effort mode is requested. knownInputs.knownInputIds.forEach(({container: c}) => { - if (c.incompatible !== null && !nonIgnorableIncompatibilities.includes(c.incompatible)) { - c.incompatible = null; - } + // All class incompatibilities are "filterable" right now. + c.incompatible = null; + for (const [key, i] of c.memberIncompatibility.entries()) { - if (!nonIgnorableIncompatibilities.includes(i.reason)) { + if (!nonIgnorableInputIncompatibilities.includes(i.reason)) { c.memberIncompatibility.delete(key); } } diff --git a/packages/core/schematics/migrations/signal-migration/src/index.ts b/packages/core/schematics/migrations/signal-migration/src/index.ts index aec6ae7ec206a..8656f428e6188 100644 --- a/packages/core/schematics/migrations/signal-migration/src/index.ts +++ b/packages/core/schematics/migrations/signal-migration/src/index.ts @@ -21,11 +21,10 @@ main(path.resolve(process.argv[2]), process.argv.includes('--best-effort-mode')) * Runs the signal input migration for the given TypeScript project. */ export async function main(absoluteTsconfigPath: string, bestEffortMode: boolean) { - const migration = new SignalInputMigration(); - - migration.bestEffortMode = bestEffortMode; - migration.upgradeAnalysisPhaseToAvoidBatch = true; - + const migration = new SignalInputMigration({ + bestEffortMode, + upgradeAnalysisPhaseToAvoidBatch: true, + }); const baseInfo = migration.createProgram(absoluteTsconfigPath); const info = migration.prepareProgram(baseInfo); diff --git a/packages/core/schematics/migrations/signal-migration/src/input_detection/directive_info.ts b/packages/core/schematics/migrations/signal-migration/src/input_detection/directive_info.ts index 5d695fd682122..497c8fb00c53e 100644 --- a/packages/core/schematics/migrations/signal-migration/src/input_detection/directive_info.ts +++ b/packages/core/schematics/migrations/signal-migration/src/input_detection/directive_info.ts @@ -24,21 +24,23 @@ export class DirectiveInfo { /** Map of input IDs and their incompatibilities. */ memberIncompatibility = new Map(); - /** Whether the whole class is incompatible. */ + /** + * Whether the whole class is incompatible. + * + * Class incompatibility precedes individual member incompatibility. + * All members in the class are considered incompatible. + */ incompatible: ClassIncompatibilityReason | null = null; constructor(public clazz: ts.ClassDeclaration) {} /** - * Checks whether the class is skipped for migration because all of - * the inputs are marked as incompatible, or the class itself. + * Checks whether there are any incompatible inputs for the + * given class. */ - isClassSkippedForMigration(): boolean { - return ( - this.incompatible !== null || - Array.from(this.inputFields.values()).every(({descriptor}) => - this.isInputMemberIncompatible(descriptor), - ) + hasIncompatibleMembers(): boolean { + return Array.from(this.inputFields.values()).some(({descriptor}) => + this.isInputMemberIncompatible(descriptor), ); } diff --git a/packages/core/schematics/migrations/signal-migration/src/input_detection/incompatibility.ts b/packages/core/schematics/migrations/signal-migration/src/input_detection/incompatibility.ts index 9c1d013e425a5..a2ea8a227dfe1 100644 --- a/packages/core/schematics/migrations/signal-migration/src/input_detection/incompatibility.ts +++ b/packages/core/schematics/migrations/signal-migration/src/input_detection/incompatibility.ts @@ -10,6 +10,7 @@ import ts from 'typescript'; /** Reasons why an input cannot be migrated. */ export enum InputIncompatibilityReason { + SkippedViaConfigFilter, Accessor, WriteAssignment, OverriddenByDerivedClass, @@ -18,7 +19,6 @@ export enum InputIncompatibilityReason { ParentIsIncompatible, SpyOnThatOverwritesField, PotentiallyNarrowedInTemplateButNoSupportYet, - IgnoredBecauseOfLanguageServiceRefactoringRange, RequiredInputButNoGoodExplicitTypeExtractable, } @@ -34,16 +34,6 @@ export interface InputMemberIncompatibility { context: ts.Node | null; } -/** Reasons that cannot be ignored. */ -export const nonIgnorableIncompatibilities: Array< - InputIncompatibilityReason | ClassIncompatibilityReason -> = [ - // There is no good output for accessor inputs. - InputIncompatibilityReason.Accessor, - // There is no good output for such inputs. We can't perform "conversion". - InputIncompatibilityReason.RequiredInputButNoGoodExplicitTypeExtractable, -]; - /** Whether the given value refers to an input member incompatibility. */ export function isInputMemberIncompatibility(value: unknown): value is InputMemberIncompatibility { return ( diff --git a/packages/core/schematics/migrations/signal-migration/src/migration.ts b/packages/core/schematics/migrations/signal-migration/src/migration.ts index bfc5dd24153fa..63f085165c7a4 100644 --- a/packages/core/schematics/migrations/signal-migration/src/migration.ts +++ b/packages/core/schematics/migrations/signal-migration/src/migration.ts @@ -11,7 +11,7 @@ import {confirmAsSerializable, Serializable} from '../../../utils/tsurge/helpers import {BaseProgramInfo, ProgramInfo} from '../../../utils/tsurge/program_info'; import {TsurgeComplexMigration} from '../../../utils/tsurge/migration'; import {CompilationUnitData} from './batch/unit_data'; -import {KnownInputs} from './input_detection/known_inputs'; +import {KnownInputInfo, KnownInputs} from './input_detection/known_inputs'; import {AnalysisProgramInfo, prepareAnalysisInfo} from './analysis_deps'; import {MigrationResult} from './result'; import {MigrationHost} from './migration_host'; @@ -26,6 +26,44 @@ import {executeMigrationPhase} from './phase_migrate'; import {filterIncompatibilitiesForBestEffortMode} from './best_effort_mode'; import {createNgtscProgram} from '../../../utils/tsurge/helpers/ngtsc_program'; import assert from 'assert'; +import {InputIncompatibilityReason} from './input_detection/incompatibility'; +import {InputUniqueKey, isInputDescriptor} from './utils/input_id'; + +export interface MigrationConfig { + /** + * Whether to migrate as much as possible, even if certain inputs would otherwise + * be marked as incompatible for migration. + */ + bestEffortMode?: boolean; + + /** + * Whether the given input should be migrated. With batch execution, this + * callback fires for foreign inputs from other compilation units too. + * + * Treating the input as non-migrated means that no references to it are + * migrated. + */ + shouldMigrateInput?: (input: KnownInputInfo) => boolean; + + /** + * Whether to upgrade analysis phase to avoid batch execution. + * + * This is useful when not running against multiple compilation units. + * The analysis phase will re-use the same program and information, without + * re-analyzing in the `migrate` phase. + * + * Results will be available as {@link SignalInputMigration#upgradedAnalysisPhaseResults} + * after executing the analyze stage. + */ + upgradeAnalysisPhaseToAvoidBatch?: boolean; + + /** + * Optional function to receive updates on progress of the migration. Useful + * for integration with the language service to give some kind of indication + * what the migration is currently doing. + */ + reportProgressFn?: (percentage: number, updateMessage: string) => void; +} /** * Tsurge migration for migrating Angular `@Input()` declarations to @@ -35,19 +73,15 @@ export class SignalInputMigration extends TsurgeComplexMigration< CompilationUnitData, CompilationUnitData > { - upgradeAnalysisPhaseToAvoidBatch = false; upgradedAnalysisPhaseResults: { replacements: Replacement[]; projectAbsDirPath: AbsoluteFsPath; + knownInputs: KnownInputs; } | null = null; - // Necessary for language service configuration. - reportProgressFn: ((percentage: number, updateMessage: string) => void) | null = null; - beforeMigrateHook: - | ((host: MigrationHost, knownInputs: KnownInputs, result: MigrationResult) => void) - | null = null; - - bestEffortMode = false; + constructor(private readonly config: MigrationConfig = {}) { + super(); + } // Override the default ngtsc program creation, to add extra flags. override createProgram(tsconfigAbsPath: string, fs?: FileSystem): BaseProgramInfo { @@ -79,22 +113,22 @@ export class SignalInputMigration extends TsurgeComplexMigration< const result = new MigrationResult(); const host = createMigrationHost(info); - this.reportProgressFn?.(10, 'Analyzing project (input usages)..'); + this.config.reportProgressFn?.(10, 'Analyzing project (input usages)..'); const {inheritanceGraph} = executeAnalysisPhase(host, knownInputs, result, analysisDeps); - this.reportProgressFn?.(40, 'Checking inheritance..'); + this.config.reportProgressFn?.(40, 'Checking inheritance..'); pass4__checkInheritanceOfInputs(host, inheritanceGraph, metaRegistry, knownInputs); - if (this.bestEffortMode) { + if (this.config.bestEffortMode) { filterIncompatibilitiesForBestEffortMode(knownInputs); } const unitData = getCompilationUnitMetadata(knownInputs, result); // Non-batch mode! - if (this.upgradeAnalysisPhaseToAvoidBatch) { + if (this.config.upgradeAnalysisPhaseToAvoidBatch) { const merged = await this.merge([unitData]); - this.reportProgressFn?.(60, 'Collecting migration changes..'); + this.config.reportProgressFn?.(60, 'Collecting migration changes..'); const replacements = await this.migrate(merged, info, { knownInputs, result, @@ -102,10 +136,14 @@ export class SignalInputMigration extends TsurgeComplexMigration< inheritanceGraph, analysisDeps, }); - this.reportProgressFn?.(100, 'Completed migration.'); + this.config.reportProgressFn?.(100, 'Completed migration.'); // Expose the upgraded analysis stage results. - this.upgradedAnalysisPhaseResults = {replacements, projectAbsDirPath: info.projectDirAbsPath}; + this.upgradedAnalysisPhaseResults = { + replacements, + projectAbsDirPath: info.projectDirAbsPath, + knownInputs, + }; } return confirmAsSerializable(unitData); @@ -144,20 +182,55 @@ export class SignalInputMigration extends TsurgeComplexMigration< analysisDeps.metaRegistry, knownInputs, ); - if (this.bestEffortMode) { + if (this.config.bestEffortMode) { filterIncompatibilitiesForBestEffortMode(knownInputs); } } - // Optional before migrate hook. Used by the language service. - this.beforeMigrateHook?.(host, knownInputs, result); - + filterInputsViaConfig(result, knownInputs, this.config); executeMigrationPhase(host, knownInputs, result, analysisDeps); return result.replacements; } } +/** + * Updates the migration state to filter inputs based on a filter + * method defined in the migration config. + */ +function filterInputsViaConfig( + result: MigrationResult, + knownInputs: KnownInputs, + config: MigrationConfig, +) { + if (config.shouldMigrateInput === undefined) { + return; + } + + const skippedInputs = new Set(); + + // Mark all skipped inputs as incompatible for migration. + for (const input of knownInputs.knownInputIds.values()) { + if (!config.shouldMigrateInput(input)) { + skippedInputs.add(input.descriptor.key); + knownInputs.markInputAsIncompatible(input.descriptor, { + context: null, + reason: InputIncompatibilityReason.SkippedViaConfigFilter, + }); + } + } + + result.references = result.references.filter((reference) => { + if (isInputDescriptor(reference.target)) { + // Only migrate the reference if the target is NOT skipped. + return !skippedInputs.has(reference.target.key); + } + // Class references may be migrated. This is up to the logic handling + // the class reference. E.g. it may not migrate if any member is incompatible. + return true; + }); +} + function createMigrationHost(info: ProgramInfo): MigrationHost { return new MigrationHost( /* projectDir */ info.projectDirAbsPath, diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/9_migrate_ts_type_references.ts b/packages/core/schematics/migrations/signal-migration/src/passes/9_migrate_ts_type_references.ts index ecf5fca562c53..bf7bbd5f0e3da 100644 --- a/packages/core/schematics/migrations/signal-migration/src/passes/9_migrate_ts_type_references.ts +++ b/packages/core/schematics/migrations/signal-migration/src/passes/9_migrate_ts_type_references.ts @@ -34,8 +34,8 @@ export function pass9__migrateTypeScriptTypeReferences( if (!isTsInputClassTypeReference(reference)) { continue; } - // Skip references to classes that are not migrated. - if (knownInputs.getDirectiveInfoForClass(reference.target)!.isClassSkippedForMigration()) { + // Skip references to classes that are not fully migrated. + if (knownInputs.getDirectiveInfoForClass(reference.target)?.hasIncompatibleMembers()) { continue; } // Skip duplicate references. E.g. in batching. diff --git a/packages/core/schematics/migrations/signal-migration/src/phase_analysis.ts b/packages/core/schematics/migrations/signal-migration/src/phase_analysis.ts index e0d18f2403f07..4e3b39bb75874 100644 --- a/packages/core/schematics/migrations/signal-migration/src/phase_analysis.ts +++ b/packages/core/schematics/migrations/signal-migration/src/phase_analysis.ts @@ -16,7 +16,6 @@ import {pass2_IdentifySourceFileReferences} from './passes/2_find_source_file_re import {MigrationResult} from './result'; import {InheritanceGraph} from './utils/inheritance_graph'; import {GroupedTsAstVisitor} from './utils/grouped_ts_ast_visitor'; -import {nonIgnorableIncompatibilities} from './input_detection/incompatibility'; /** * Executes the analysis phase of the migration. diff --git a/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt b/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt index b6c385277a205..dbb106929a505 100644 --- a/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt +++ b/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt @@ -647,11 +647,11 @@ new ManualInstantiation(); // tslint:disable -import {Component, Input} from '@angular/core'; +import { Component, input } from '@angular/core'; @Component({}) export class ManualInstantiation { - @Input() bla: string = ''; + readonly bla = input(''); } @@@@@@ modifier_tests.ts @@@@@@ @@ -934,10 +934,10 @@ export class ScopeMismatchTest { // tslint:disable -import {Input} from '@angular/core'; +import { input } from '@angular/core'; class MyComp { - @Input() myInput = () => {}; + readonly myInput = input(() => { }); } spyOn(new MyComp(), 'myInput').and.returnValue(); diff --git a/packages/language-service/src/refactorings/convert_to_signal_input.ts b/packages/language-service/src/refactorings/convert_to_signal_input.ts index fe193a02dce4f..3483439043978 100644 --- a/packages/language-service/src/refactorings/convert_to_signal_input.ts +++ b/packages/language-service/src/refactorings/convert_to_signal_input.ts @@ -42,8 +42,6 @@ export class ConvertToSignalInputRefactoring implements Refactoring { id = 'convert-to-signal-input'; description = '(experimental fixer): Convert @Input() to a signal input'; - migration: SignalInputMigration | null = null; - isApplicable( compiler: NgCompiler, fileName: string, @@ -113,20 +111,14 @@ export class ConvertToSignalInputRefactoring implements Refactoring { } reportProgress(0, 'Starting input migration. Analyzing..'); - // TS incorrectly narrows to `null` if we don't explicitly widen the type. - // See: https://github.com/microsoft/TypeScript/issues/11498. - let targetInput: KnownInputInfo | null = null as KnownInputInfo | null; - - this.migration ??= new SignalInputMigration(); - this.migration.upgradeAnalysisPhaseToAvoidBatch = true; - this.migration.reportProgressFn = reportProgress; - this.migration.beforeMigrateHook = getBeforeMigrateHookToFilterAllUnrelatedInputs( - containingProp, - (i) => (targetInput = i), - ); + const migration = new SignalInputMigration({ + upgradeAnalysisPhaseToAvoidBatch: true, + reportProgressFn: reportProgress, + shouldMigrateInput: (input) => input.descriptor.node === containingProp, + }); - await this.migration.analyze( - this.migration.prepareProgram({ + await migration.analyze( + migration.prepareProgram({ ngCompiler: compiler, program: compiler.getCurrentProgram(), userOptions: compilerOptions, @@ -135,14 +127,26 @@ export class ConvertToSignalInputRefactoring implements Refactoring { }), ); - if (this.migration.upgradedAnalysisPhaseResults === null || targetInput === null) { + if (migration.upgradedAnalysisPhaseResults === null) { return { edits: [], - notApplicableReason: 'Unexpected error. No edits could be computed.', + notApplicableReason: 'Unexpected error. No analysis result is available.', }; } - // Check for incompatibility, and report if it prevented migration. + const {knownInputs, replacements} = migration.upgradedAnalysisPhaseResults; + const targetInput = Array.from(knownInputs.knownInputIds.values()).find( + (i) => i.descriptor.node === containingProp, + ); + + if (targetInput === undefined) { + return { + edits: [], + notApplicableReason: 'Unexpected error. Could not find target input in registry.', + }; + } + + // Check for incompatibility, and report when it prevented migration. if (targetInput.isIncompatible()) { const {container, descriptor} = targetInput; const memberIncompatibility = container.memberIncompatibility.get(descriptor.key); @@ -161,9 +165,8 @@ export class ConvertToSignalInputRefactoring implements Refactoring { }; } - const edits: ts.FileTextChanges[] = Array.from( - groupReplacementsByFile(this.migration.upgradedAnalysisPhaseResults.replacements).entries(), - ).map(([fileName, changes]) => { + const fileUpdates = Array.from(groupReplacementsByFile(replacements).entries()); + const edits: ts.FileTextChanges[] = fileUpdates.map(([fileName, changes]) => { return { fileName, textChanges: changes.map((c) => ({ @@ -196,37 +199,3 @@ function findParentPropertyDeclaration(node: ts.Node): ts.PropertyDeclaration | } return node; } - -function getBeforeMigrateHookToFilterAllUnrelatedInputs( - containingProp: InputNode, - setTargetInput: (i: KnownInputInfo) => void, -): SignalInputMigration['beforeMigrateHook'] { - return (host, knownInputs, result) => { - const {key} = getInputDescriptor(host, containingProp); - const targetInput = knownInputs.get({key}); - - if (targetInput === undefined) { - return; - } - - setTargetInput(targetInput); - - // Mark all other inputs as incompatible. - // Note that we still analyzed the whole application for potential references. - // Only migrate references to the target input. - for (const input of result.sourceInputs.keys()) { - if (input.key !== key) { - knownInputs.markInputAsIncompatible(input, { - context: null, - reason: InputIncompatibilityReason.IgnoredBecauseOfLanguageServiceRefactoringRange, - }); - } - } - - result.references = result.references.filter( - // Note: References to the whole class are not migrated as we are not migrating all inputs. - // We can revisit this at a later time. - (r) => isInputDescriptor(r.target) && r.target.key === key, - ); - }; -} From e6e5d29e830a0a74d7677d5f2345f29391064853 Mon Sep 17 00:00:00 2001 From: Pawel Kozlowski Date: Thu, 29 Aug 2024 11:57:42 +0200 Subject: [PATCH 11/41] feat(core): initial version of the output migration (#57604) Initial version of the migration that changes decorator-based outputs to the equivalent form using new authoring functions. PR Close #57604 --- .../migrations/output-migration/BUILD.bazel | 42 ++++ .../output-migration/output-migration.spec.ts | 217 ++++++++++++++++++ .../output-migration/output-migration.ts | 185 +++++++++++++++ .../output-migration/output-replacements.ts | 102 ++++++++ .../output-migration/output_helpers.ts | 125 ++++++++++ 5 files changed, 671 insertions(+) create mode 100644 packages/core/schematics/migrations/output-migration/BUILD.bazel create mode 100644 packages/core/schematics/migrations/output-migration/output-migration.spec.ts create mode 100644 packages/core/schematics/migrations/output-migration/output-migration.ts create mode 100644 packages/core/schematics/migrations/output-migration/output-replacements.ts create mode 100644 packages/core/schematics/migrations/output-migration/output_helpers.ts diff --git a/packages/core/schematics/migrations/output-migration/BUILD.bazel b/packages/core/schematics/migrations/output-migration/BUILD.bazel new file mode 100644 index 0000000000000..7573766f2a4cd --- /dev/null +++ b/packages/core/schematics/migrations/output-migration/BUILD.bazel @@ -0,0 +1,42 @@ +load("//tools:defaults.bzl", "jasmine_node_test", "ts_library") + +ts_library( + name = "migration", + srcs = glob( + ["**/*.ts"], + exclude = ["*.spec.ts"], + ), + deps = [ + "//packages/compiler", + "//packages/compiler-cli", + "//packages/compiler-cli/private", + "//packages/compiler-cli/src/ngtsc/annotations", + "//packages/compiler-cli/src/ngtsc/annotations/directive", + "//packages/compiler-cli/src/ngtsc/imports", + "//packages/compiler-cli/src/ngtsc/metadata", + "//packages/compiler-cli/src/ngtsc/reflection", + "//packages/core/schematics/utils/tsurge", + "@npm//@types/node", + "@npm//typescript", + ], +) + +ts_library( + name = "test_lib", + testonly = True, + srcs = glob( + ["**/*.spec.ts"], + ), + deps = [ + ":migration", + "//packages/compiler-cli", + "//packages/compiler-cli/src/ngtsc/file_system/testing", + "//packages/core/schematics/utils/tsurge", + ], +) + +jasmine_node_test( + name = "test", + srcs = [":test_lib"], + env = {"FORCE_COLOR": "3"}, +) diff --git a/packages/core/schematics/migrations/output-migration/output-migration.spec.ts b/packages/core/schematics/migrations/output-migration/output-migration.spec.ts new file mode 100644 index 0000000000000..274535805a890 --- /dev/null +++ b/packages/core/schematics/migrations/output-migration/output-migration.spec.ts @@ -0,0 +1,217 @@ +/** + * @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.io/license + */ + +import {initMockFileSystem} from '../../../../compiler-cli/src/ngtsc/file_system/testing'; +import {runTsurgeMigration} from '../../utils/tsurge/testing'; +import {absoluteFrom} from '@angular/compiler-cli'; +import {OutputMigration} from './output-migration'; + +describe('outputs', () => { + beforeEach(() => { + initMockFileSystem('Native'); + }); + + describe('outputs migration', () => { + describe('EventEmitter declarations without problematic access patterns', () => { + it('should migrate declaration with a primitive type hint', () => { + verifyDeclaration({ + before: '@Output() readonly someChange = new EventEmitter();', + after: 'readonly someChange = output();', + }); + }); + + it('should migrate declaration with complex type hint', () => { + verifyDeclaration({ + before: '@Output() readonly someChange = new EventEmitter();', + after: 'readonly someChange = output();', + }); + }); + + it('should migrate declaration without type hint', () => { + verifyDeclaration({ + before: '@Output() readonly someChange = new EventEmitter();', + after: 'readonly someChange = output();', + }); + }); + + it('should take alias into account', () => { + verifyDeclaration({ + before: `@Output({alias: 'otherChange'}) readonly someChange = new EventEmitter();`, + after: `readonly someChange = output({ alias: 'otherChange' });`, + }); + }); + + it('should support alias as statically analyzable reference', () => { + verify({ + before: ` + import {Directive, Output, EventEmitter} from '@angular/core'; + + const aliasParam = { alias: 'otherChange' } as const; + + @Directive() + export class TestDir { + @Output(aliasParam) someChange = new EventEmitter(); + } + `, + after: ` + import { Directive, output } from '@angular/core'; + + const aliasParam = { alias: 'otherChange' } as const; + + @Directive() + export class TestDir { + readonly someChange = output(aliasParam); + } + `, + }); + }); + + it('should add readonly modifier', () => { + verifyDeclaration({ + before: '@Output() someChange = new EventEmitter();', + after: 'readonly someChange = output();', + }); + }); + + it('should respect visibility modifiers', () => { + verifyDeclaration({ + before: `@Output() protected someChange = new EventEmitter();`, + after: `protected readonly someChange = output();`, + }); + }); + + it('should migrate multiple outputs', () => { + // TODO: whitespace are messing up test verification + verifyDeclaration({ + before: `@Output() someChange1 = new EventEmitter(); + @Output() someChange2 = new EventEmitter();`, + after: `readonly someChange1 = output(); + readonly someChange2 = output();`, + }); + }); + + it('should migrate only EventEmitter outputs when multiple outputs exist', () => { + // TODO: whitespace are messing up test verification + verifyDeclaration({ + before: `@Output() someChange1 = new EventEmitter(); + @Output() someChange2 = new Subject();`, + after: `readonly someChange1 = output(); + @Output() someChange2 = new Subject();`, + }); + }); + }); + + describe('declarations _with_ problematic access patterns', () => { + it('should _not_ migrate outputs that are used with .pipe', () => { + verifyNoChange(` + import {Directive, Output, EventEmitter} from '@angular/core'; + + @Directive() + export class TestDir { + @Output() someChange = new EventEmitter(); + + someMethod() { + this.someChange.pipe(); + } + } + `); + }); + + it('should _not_ migrate outputs that are used with .next', () => { + verifyNoChange(` + import {Directive, Output, EventEmitter} from '@angular/core'; + + @Directive() + export class TestDir { + @Output() someChange = new EventEmitter(); + + someMethod() { + this.someChange.next('payload'); + } + } + `); + }); + + it('should _not_ migrate outputs that are used with .complete', () => { + verifyNoChange(` + import {Directive, Output, EventEmitter, OnDestroy} from '@angular/core'; + + @Directive() + export class TestDir implements OnDestroy { + @Output() someChange = new EventEmitter(); + + ngOnDestroy() { + this.someChange.complete(); + } + } + `); + }); + }); + }); + + describe('declarations other than EventEmitter', () => { + it('should _not_ migrate outputs that are initialized with sth else than EventEmitter', () => { + verify({ + before: populateDeclarationTestCase('@Output() protected someChange = new Subject();'), + after: populateDeclarationTestCase('@Output() protected someChange = new Subject();'), + }); + }); + }); +}); + +async function verifyDeclaration(testCase: {before: string; after: string}) { + verify({ + before: populateDeclarationTestCase(testCase.before), + after: populateExpectedResult(testCase.after), + }); +} + +async function verifyNoChange(beforeAndAfter: string) { + verify({before: beforeAndAfter, after: beforeAndAfter}); +} + +async function verify(testCase: {before: string; after: string}) { + const fs = await runTsurgeMigration(new OutputMigration(), [ + { + name: absoluteFrom('/app.component.ts'), + isProgramRootFile: true, + contents: testCase.before, + }, + ]); + + let actual = fs.readFile(absoluteFrom('/app.component.ts')); + + expect(actual).toBe(testCase.after); +} + +function populateDeclarationTestCase(declaration: string): string { + return ` + import { + Directive, + Output, + EventEmitter, + Subject + } from '@angular/core'; + + @Directive() + export class TestDir { + ${declaration} + } + `; +} + +function populateExpectedResult(declaration: string): string { + return ` + import { Directive, Subject, output } from '@angular/core'; + + @Directive() + export class TestDir { + ${declaration} + } + `; +} diff --git a/packages/core/schematics/migrations/output-migration/output-migration.ts b/packages/core/schematics/migrations/output-migration/output-migration.ts new file mode 100644 index 0000000000000..420ef96a1f1e2 --- /dev/null +++ b/packages/core/schematics/migrations/output-migration/output-migration.ts @@ -0,0 +1,185 @@ +/** + * @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.io/license + */ + +import ts from 'typescript'; +import { + confirmAsSerializable, + ProgramInfo, + ProjectRelativePath, + Replacement, + Serializable, + TsurgeFunnelMigration, + projectRelativePath, +} from '../../utils/tsurge'; + +import {DtsMetadataReader} from '../../../../compiler-cli/src/ngtsc/metadata'; +import {TypeScriptReflectionHost} from '../../../../compiler-cli/src/ngtsc/reflection'; +import { + isOutputDeclaration, + OutputID, + getUniqueIdForProperty, + getTargetPropertyDeclaration, + extractSourceOutputDefinition, + isProblematicEventEmitterUsage, +} from './output_helpers'; +import {calculateImportReplacements, calculateDeclarationReplacements} from './output-replacements'; + +interface OutputMigrationData { + path: ProjectRelativePath; + replacements: Replacement[]; +} + +interface CompilationUnitData { + outputFields: Record; + problematicUsages: Record; + importReplacements: Record< + ProjectRelativePath, + {add: Replacement[]; addAndRemove: Replacement[]} + >; +} + +export class OutputMigration extends TsurgeFunnelMigration< + CompilationUnitData, + CompilationUnitData +> { + override async analyze({ + sourceFiles, + program, + projectDirAbsPath, + }: ProgramInfo): Promise> { + const outputFields: Record = {}; + const problematicUsages: Record = {}; + + const filesWithOutputDeclarations = new Set(); + + const checker = program.getTypeChecker(); + const reflector = new TypeScriptReflectionHost(checker); + const dtsReader = new DtsMetadataReader(checker, reflector); + + const outputMigrationVisitor = (node: ts.Node) => { + // detect output declarations + if (ts.isPropertyDeclaration(node)) { + const outputDef = extractSourceOutputDefinition(node, reflector, projectDirAbsPath); + if (outputDef !== null) { + const relativePath = projectRelativePath(node.getSourceFile(), projectDirAbsPath); + + filesWithOutputDeclarations.add(relativePath); + outputFields[outputDef.id] = { + path: relativePath, + replacements: calculateDeclarationReplacements( + projectDirAbsPath, + node, + outputDef.aliasParam, + ), + }; + } + } + + // detect unsafe access of the output property + if (isProblematicEventEmitterUsage(node)) { + const targetSymbol = checker.getSymbolAtLocation(node.expression); + if (targetSymbol !== undefined) { + const propertyDeclaration = getTargetPropertyDeclaration(targetSymbol); + if ( + propertyDeclaration !== null && + isOutputDeclaration(propertyDeclaration, reflector, dtsReader) + ) { + const id = getUniqueIdForProperty(projectDirAbsPath, propertyDeclaration); + problematicUsages[id] = true; + } + } + } + + ts.forEachChild(node, outputMigrationVisitor); + }; + + // calculate output migration replacements + for (const sf of sourceFiles) { + ts.forEachChild(sf, outputMigrationVisitor); + } + + // calculate import replacements but do so only for files that have output declarations + const importReplacements = calculateImportReplacements( + projectDirAbsPath, + sourceFiles.filter((sf) => + filesWithOutputDeclarations.has(projectRelativePath(sf, projectDirAbsPath)), + ), + ); + + return confirmAsSerializable({ + outputFields, + importReplacements, + problematicUsages, + }); + } + + override async merge(units: CompilationUnitData[]): Promise> { + const outputFields: Record = {}; + const importReplacements: Record< + ProjectRelativePath, + {add: Replacement[]; addAndRemove: Replacement[]} + > = {}; + const problematicUsages: Record = {}; + + for (const unit of units) { + for (const declIdStr of Object.keys(unit.outputFields)) { + const declId = declIdStr as OutputID; + // THINK: detect clash? Should we have an utility to merge data based on unique IDs? + outputFields[declId] = unit.outputFields[declId]; + } + + for (const pathStr of Object.keys(unit.importReplacements)) { + const path = pathStr as ProjectRelativePath; + importReplacements[path] = unit.importReplacements[path]; + } + + for (const declIdStr of Object.keys(unit.problematicUsages)) { + const declId = declIdStr as OutputID; + problematicUsages[declId] = unit.problematicUsages[declId]; + } + } + + return confirmAsSerializable({ + outputFields, + importReplacements, + problematicUsages, + }); + } + + override async migrate(globalData: CompilationUnitData): Promise { + const migratedFiles = new Set(); + const problematicFiles = new Set(); + + const replacements: Replacement[] = []; + for (const declIdStr of Object.keys(globalData.outputFields)) { + const declId = declIdStr as OutputID; + const outputField = globalData.outputFields[declId]; + + if (!globalData.problematicUsages[declId]) { + replacements.push(...outputField.replacements); + migratedFiles.add(outputField.path); + } else { + problematicFiles.add(outputField.path); + } + } + + for (const pathStr of Object.keys(globalData.importReplacements)) { + const path = pathStr as ProjectRelativePath; + if (migratedFiles.has(path)) { + const importReplacements = globalData.importReplacements[path]; + if (problematicFiles.has(path)) { + replacements.push(...importReplacements.add); + } else { + replacements.push(...importReplacements.addAndRemove); + } + } + } + + return replacements; + } +} diff --git a/packages/core/schematics/migrations/output-migration/output-replacements.ts b/packages/core/schematics/migrations/output-migration/output-replacements.ts new file mode 100644 index 0000000000000..2d8a94ddf7896 --- /dev/null +++ b/packages/core/schematics/migrations/output-migration/output-replacements.ts @@ -0,0 +1,102 @@ +/** + * @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.io/license + */ + +import ts from 'typescript'; +import { + Replacement, + TextUpdate, + ProjectRelativePath, + projectRelativePath, +} from '../../utils/tsurge'; +import {absoluteFromSourceFile, AbsoluteFsPath} from '@angular/compiler-cli'; +import {applyImportManagerChanges} from '../../utils/tsurge/helpers/apply_import_manager'; +import {ImportManager} from '../../../../compiler-cli/private/migrations'; + +const printer = ts.createPrinter(); + +export function calculateDeclarationReplacements( + projectDirAbsPath: AbsoluteFsPath, + node: ts.PropertyDeclaration, + aliasParam?: ts.Expression, +): Replacement[] { + const sf = node.getSourceFile(); + const payloadTypes = + node.initializer !== undefined && ts.isNewExpression(node.initializer) + ? node.initializer?.typeArguments + : undefined; + + const outputCall = ts.factory.createCallExpression( + ts.factory.createIdentifier('output'), + payloadTypes, + aliasParam ? [aliasParam] : [], + ); + + const existingModifiers = (node.modifiers ?? []).filter( + (modifier) => !ts.isDecorator(modifier) && modifier.kind !== ts.SyntaxKind.ReadonlyKeyword, + ); + + const updatedOutputDeclaration = ts.factory.updatePropertyDeclaration( + node, + // Think: this logic of dealing with modifiers is applicable to all signal-based migrations + ts.factory.createNodeArray([ + ...existingModifiers, + ts.factory.createModifier(ts.SyntaxKind.ReadonlyKeyword), + ]), + node.name, + undefined, + undefined, + outputCall, + ); + + return [ + new Replacement( + projectRelativePath(sf, projectDirAbsPath), + new TextUpdate({ + position: node.getStart(), + end: node.getEnd(), + toInsert: printer.printNode(ts.EmitHint.Unspecified, updatedOutputDeclaration, sf), + }), + ), + ]; +} + +export function calculateImportReplacements( + projectDirAbsPath: AbsoluteFsPath, + sourceFiles: ts.SourceFile[], +) { + const importReplacements: Record< + ProjectRelativePath, + {add: Replacement[]; addAndRemove: Replacement[]} + > = {}; + + const importManager = new ImportManager(); + + for (const sf of sourceFiles) { + const addOnly: Replacement[] = []; + const addRemove: Replacement[] = []; + + const absolutePath = absoluteFromSourceFile(sf); + importManager.addImport({ + requestedFile: sf, + exportModuleSpecifier: '@angular/core', + exportSymbolName: 'output', + }); + applyImportManagerChanges(importManager, addOnly, [sf], projectDirAbsPath); + + importManager.removeImport(sf, 'Output', '@angular/core'); + importManager.removeImport(sf, 'EventEmitter', '@angular/core'); + applyImportManagerChanges(importManager, addRemove, [sf], projectDirAbsPath); + + importReplacements[projectRelativePath(sf, projectDirAbsPath)] = { + add: addOnly, + addAndRemove: addRemove, + }; + } + + return importReplacements; +} diff --git a/packages/core/schematics/migrations/output-migration/output_helpers.ts b/packages/core/schematics/migrations/output-migration/output_helpers.ts new file mode 100644 index 0000000000000..fe0917c5fd4fd --- /dev/null +++ b/packages/core/schematics/migrations/output-migration/output_helpers.ts @@ -0,0 +1,125 @@ +/** + * @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.io/license + */ + +import ts from 'typescript'; +import * as path from 'path'; +import { + ReflectionHost, + ClassDeclaration, + Decorator, +} from '../../../../compiler-cli/src/ngtsc/reflection'; +import {DtsMetadataReader} from '../../../../compiler-cli/src/ngtsc/metadata'; +import {Reference} from '../../../../compiler-cli/src/ngtsc/imports'; +import {getAngularDecorators} from '../../../../compiler-cli/src/ngtsc/annotations'; + +import {UniqueID} from '../../utils/tsurge'; + +/** Branded type for unique IDs of Angular `@Output`s. */ +export type OutputID = UniqueID<'output-node'>; + +/** Type describing an extracted output query that can be migrated. */ +export interface ExtractedOutput { + id: OutputID; + aliasParam?: ts.Expression; +} + +/** + * Determines if the given node refers to a decorator-based output, and + * returns its resolved metadata if possible. + */ +export function extractSourceOutputDefinition( + node: ts.PropertyDeclaration, + reflector: ReflectionHost, + projectDirAbsPath: string, +): ExtractedOutput | null { + const outputDecorator = getOutputDecorator(node, reflector); + + if (outputDecorator !== null && isOutputDeclarationEligibleForMigration(node)) { + return { + id: getUniqueIdForProperty(projectDirAbsPath, node), + aliasParam: outputDecorator.args?.at(0), + }; + } + + return null; +} + +function isOutputDeclarationEligibleForMigration(node: ts.PropertyDeclaration) { + return ( + node.initializer !== undefined && + ts.isNewExpression(node.initializer) && + ts.isIdentifier(node.initializer.expression) && + node.initializer.expression.text === 'EventEmitter' + ); +} + +const problematicEventEmitterUsages = new Set(['pipe', 'next', 'complete']); +export function isProblematicEventEmitterUsage(node: ts.Node): node is ts.PropertyAccessExpression { + return ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.name) && + problematicEventEmitterUsages.has(node.name.text) + ); +} + +/** Gets whether the given property is an Angular `@Output`. */ +export function isOutputDeclaration( + node: ts.PropertyDeclaration, + reflector: ReflectionHost, + dtsReader: DtsMetadataReader, +): boolean { + // `.d.ts` file, so we check the `static ecmp` metadata on the `declare class`. + if (node.getSourceFile().isDeclarationFile) { + if ( + !ts.isIdentifier(node.name) || + !ts.isClassDeclaration(node.parent) || + node.parent.name === undefined + ) { + return false; + } + + const ref = new Reference(node.parent as ClassDeclaration); + const directiveMeta = dtsReader.getDirectiveMetadata(ref); + return !!directiveMeta?.outputs.getByClassPropertyName(node.name.text); + } + + // `.ts` file, so we check for the `@Output()` decorator. + return getOutputDecorator(node, reflector) !== null; +} + +export function getTargetPropertyDeclaration( + targetSymbol: ts.Symbol, +): ts.PropertyDeclaration | null { + const valDeclaration = targetSymbol.valueDeclaration; + if (valDeclaration !== undefined && ts.isPropertyDeclaration(valDeclaration)) { + return valDeclaration; + } + return null; +} + +/** Returns Angular `@Output` decorator or null when a given property declaration is not an @Output */ +function getOutputDecorator( + node: ts.PropertyDeclaration, + reflector: ReflectionHost, +): Decorator | null { + const decorators = reflector.getDecoratorsOfDeclaration(node); + const ngDecorators = + decorators !== null ? getAngularDecorators(decorators, ['Output'], /* isCore */ false) : []; + + return ngDecorators.length > 0 ? ngDecorators[0] : null; +} + +// THINK: this utility + type is not specific to @Output, really, maybe move it to tsurge? +/** Computes an unique ID for a given Angular `@Output` property. */ +export function getUniqueIdForProperty( + projectDirAbsPath: string, + prop: ts.PropertyDeclaration, +): OutputID { + const fileId = path.relative(projectDirAbsPath, prop.getSourceFile().fileName); + return `${fileId}@@${prop.parent.name ?? 'unknown-class'}@@${prop.name.getText()}` as OutputID; +} From aa8eb15ddf35f8430fdb7ef627f044d95daa0562 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 30 Aug 2024 13:11:30 +0200 Subject: [PATCH 12/41] build: delete v18 migrations (#57603) We don't need to ship the migrations for v18 once we're in v19. PR Close #57603 --- packages/core/schematics/BUILD.bazel | 3 - packages/core/schematics/migrations.json | 18 +- .../migrations/after-render-phase/BUILD.bazel | 33 -- .../migrations/after-render-phase/index.ts | 58 -- .../after-render-phase/migration.ts | 95 ---- .../migrations/http-providers/BUILD.bazel | 33 -- .../migrations/http-providers/README.md | 93 ---- .../migrations/http-providers/index.ts | 59 -- .../migrations/http-providers/utils.ts | 521 ------------------ .../invalid-two-way-bindings/BUILD.bazel | 34 -- .../invalid-two-way-bindings/README.md | 38 -- .../invalid-two-way-bindings/analysis.ts | 118 ---- .../invalid-two-way-bindings/index.ts | 72 --- .../invalid-two-way-bindings/migration.ts | 263 --------- packages/core/schematics/test/BUILD.bazel | 6 - .../test/after_render_phase_spec.ts | 182 ------ .../schematics/test/all-migrations.spec.ts | 14 +- .../schematics/test/http_providers_spec.ts | 478 ---------------- .../test/invalid_two_way_bindings_spec.ts | 396 ------------- 19 files changed, 9 insertions(+), 2505 deletions(-) delete mode 100644 packages/core/schematics/migrations/after-render-phase/BUILD.bazel delete mode 100644 packages/core/schematics/migrations/after-render-phase/index.ts delete mode 100644 packages/core/schematics/migrations/after-render-phase/migration.ts delete mode 100644 packages/core/schematics/migrations/http-providers/BUILD.bazel delete mode 100644 packages/core/schematics/migrations/http-providers/README.md delete mode 100644 packages/core/schematics/migrations/http-providers/index.ts delete mode 100644 packages/core/schematics/migrations/http-providers/utils.ts delete mode 100644 packages/core/schematics/migrations/invalid-two-way-bindings/BUILD.bazel delete mode 100644 packages/core/schematics/migrations/invalid-two-way-bindings/README.md delete mode 100644 packages/core/schematics/migrations/invalid-two-way-bindings/analysis.ts delete mode 100644 packages/core/schematics/migrations/invalid-two-way-bindings/index.ts delete mode 100644 packages/core/schematics/migrations/invalid-two-way-bindings/migration.ts delete mode 100644 packages/core/schematics/test/after_render_phase_spec.ts delete mode 100644 packages/core/schematics/test/http_providers_spec.ts delete mode 100644 packages/core/schematics/test/invalid_two_way_bindings_spec.ts diff --git a/packages/core/schematics/BUILD.bazel b/packages/core/schematics/BUILD.bazel index ae6dfe3190261..12006deb4d733 100644 --- a/packages/core/schematics/BUILD.bazel +++ b/packages/core/schematics/BUILD.bazel @@ -20,9 +20,6 @@ pkg_npm( validate = False, visibility = ["//packages/core:__pkg__"], deps = [ - "//packages/core/schematics/migrations/after-render-phase:bundle", - "//packages/core/schematics/migrations/http-providers:bundle", - "//packages/core/schematics/migrations/invalid-two-way-bindings:bundle", "//packages/core/schematics/ng-generate/control-flow-migration:bundle", "//packages/core/schematics/ng-generate/inject-migration:bundle", "//packages/core/schematics/ng-generate/route-lazy-loading:bundle", diff --git a/packages/core/schematics/migrations.json b/packages/core/schematics/migrations.json index 1ef729caa9bd6..63001b4458891 100644 --- a/packages/core/schematics/migrations.json +++ b/packages/core/schematics/migrations.json @@ -1,19 +1,3 @@ { - "schematics": { - "invalid-two-way-bindings": { - "version": "18.0.0", - "description": "Updates two-way bindings that have an invalid expression to use the longform expression instead.", - "factory": "./migrations/invalid-two-way-bindings/bundle" - }, - "migration-http-providers": { - "version": "18.0.0", - "description": "Replace deprecated HTTP related modules with provider functions", - "factory": "./migrations/http-providers/bundle" - }, - "migration-after-render-phase": { - "version": "18.1.0", - "description": "Updates calls to afterRender with an explicit phase to the new API", - "factory": "./migrations/after-render-phase/bundle" - } - } + "schematics": {} } diff --git a/packages/core/schematics/migrations/after-render-phase/BUILD.bazel b/packages/core/schematics/migrations/after-render-phase/BUILD.bazel deleted file mode 100644 index b3ab8460a5576..0000000000000 --- a/packages/core/schematics/migrations/after-render-phase/BUILD.bazel +++ /dev/null @@ -1,33 +0,0 @@ -load("//tools:defaults.bzl", "esbuild_no_sourcemaps", "ts_library") - -package( - default_visibility = [ - "//packages/core/schematics:__pkg__", - "//packages/core/schematics/migrations/google3:__pkg__", - "//packages/core/schematics/test:__pkg__", - ], -) - -ts_library( - name = "after-render-phase", - srcs = glob(["**/*.ts"]), - tsconfig = "//packages/core/schematics:tsconfig.json", - deps = [ - "//packages/core/schematics/utils", - "@npm//@angular-devkit/schematics", - "@npm//@types/node", - "@npm//typescript", - ], -) - -esbuild_no_sourcemaps( - name = "bundle", - entry_point = ":index.ts", - external = [ - "@angular-devkit/*", - "typescript", - ], - format = "cjs", - platform = "node", - deps = [":after-render-phase"], -) diff --git a/packages/core/schematics/migrations/after-render-phase/index.ts b/packages/core/schematics/migrations/after-render-phase/index.ts deleted file mode 100644 index 46bc036ce1e86..0000000000000 --- a/packages/core/schematics/migrations/after-render-phase/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * @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.io/license - */ - -import {Rule, SchematicsException, Tree, UpdateRecorder} from '@angular-devkit/schematics'; -import {relative} from 'path'; -import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths'; -import {canMigrateFile, createMigrationProgram} from '../../utils/typescript/compiler_host'; -import {migrateFile} from './migration'; - -export default function (): Rule { - return async (tree: Tree) => { - const {buildPaths, testPaths} = await getProjectTsConfigPaths(tree); - const basePath = process.cwd(); - const allPaths = [...buildPaths, ...testPaths]; - - if (!allPaths.length) { - throw new SchematicsException( - 'Could not find any tsconfig file. Cannot run the afterRender phase migration.', - ); - } - - for (const tsconfigPath of allPaths) { - runMigration(tree, tsconfigPath, basePath); - } - }; -} - -function runMigration(tree: Tree, tsconfigPath: string, basePath: string) { - const program = createMigrationProgram(tree, tsconfigPath, basePath); - const sourceFiles = program - .getSourceFiles() - .filter((sourceFile) => canMigrateFile(basePath, sourceFile, program)); - - for (const sourceFile of sourceFiles) { - let update: UpdateRecorder | null = null; - - const rewriter = (startPos: number, width: number, text: string | null) => { - if (update === null) { - // Lazily initialize update, because most files will not require migration. - update = tree.beginUpdate(relative(basePath, sourceFile.fileName)); - } - update.remove(startPos, width); - if (text !== null) { - update.insertLeft(startPos, text); - } - }; - migrateFile(sourceFile, program.getTypeChecker(), rewriter); - - if (update !== null) { - tree.commitUpdate(update); - } - } -} diff --git a/packages/core/schematics/migrations/after-render-phase/migration.ts b/packages/core/schematics/migrations/after-render-phase/migration.ts deleted file mode 100644 index 0037e1ae29209..0000000000000 --- a/packages/core/schematics/migrations/after-render-phase/migration.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * @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.io/license - */ - -import ts from 'typescript'; -import {ChangeTracker} from '../../utils/change_tracker'; -import { - getImportOfIdentifier, - getImportSpecifier, - getNamedImports, -} from '../../utils/typescript/imports'; - -const CORE = '@angular/core'; -const AFTER_RENDER_PHASE_ENUM = 'AfterRenderPhase'; -const AFTER_RENDER_FNS = new Set(['afterRender', 'afterNextRender']); - -type RewriteFn = (startPos: number, width: number, text: string) => void; - -export function migrateFile( - sourceFile: ts.SourceFile, - typeChecker: ts.TypeChecker, - rewriteFn: RewriteFn, -) { - const changeTracker = new ChangeTracker(ts.createPrinter()); - // Check if there are any imports of the `AfterRenderPhase` enum. - const coreImports = getNamedImports(sourceFile, CORE); - if (!coreImports) { - return; - } - const phaseEnum = getImportSpecifier(sourceFile, CORE, AFTER_RENDER_PHASE_ENUM); - if (!phaseEnum) { - return; - } - - // Remove the `AfterRenderPhase` enum import. - const newCoreImports = ts.factory.updateNamedImports(coreImports, [ - ...coreImports.elements.filter((current) => phaseEnum !== current), - ]); - changeTracker.replaceNode(coreImports, newCoreImports); - ts.forEachChild(sourceFile, function visit(node: ts.Node) { - ts.forEachChild(node, visit); - - // Check if this is a function call of `afterRender` or `afterNextRender`. - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - AFTER_RENDER_FNS.has(getImportOfIdentifier(typeChecker, node.expression)?.name || '') - ) { - let phase: string | undefined; - const [callback, options] = node.arguments; - // Check if any `AfterRenderOptions` options were specified. - if (ts.isObjectLiteralExpression(options)) { - const phaseProp = options.properties.find((p) => p.name?.getText() === 'phase'); - // Check if the `phase` options is set. - if ( - phaseProp && - ts.isPropertyAssignment(phaseProp) && - ts.isPropertyAccessExpression(phaseProp.initializer) && - phaseProp.initializer.expression.getText() === AFTER_RENDER_PHASE_ENUM - ) { - phaseProp.initializer.expression; - phase = phaseProp.initializer.name.getText(); - // Remove the `phase` option. - if (options.properties.length === 1) { - changeTracker.removeNode(options); - } else { - const newOptions = ts.factory.createObjectLiteralExpression( - options.properties.filter((p) => p !== phaseProp), - ); - changeTracker.replaceNode(options, newOptions); - } - } - } - // If we found a phase, update the callback. - if (phase) { - phase = phase.substring(0, 1).toLocaleLowerCase() + phase.substring(1); - const spec = ts.factory.createObjectLiteralExpression([ - ts.factory.createPropertyAssignment(ts.factory.createIdentifier(phase), callback), - ]); - changeTracker.replaceNode(callback, spec); - } - } - }); - - // Write the changes. - for (const changesInFile of changeTracker.recordChanges().values()) { - for (const change of changesInFile) { - rewriteFn(change.start, change.removeLength ?? 0, change.text); - } - } -} diff --git a/packages/core/schematics/migrations/http-providers/BUILD.bazel b/packages/core/schematics/migrations/http-providers/BUILD.bazel deleted file mode 100644 index cf351f8b2279d..0000000000000 --- a/packages/core/schematics/migrations/http-providers/BUILD.bazel +++ /dev/null @@ -1,33 +0,0 @@ -load("//tools:defaults.bzl", "esbuild_no_sourcemaps", "ts_library") - -package( - default_visibility = [ - "//packages/core/schematics:__pkg__", - "//packages/core/schematics/migrations/google3:__pkg__", - "//packages/core/schematics/test:__pkg__", - ], -) - -ts_library( - name = "http-providers", - srcs = glob(["**/*.ts"]), - tsconfig = "//packages/core/schematics:tsconfig.json", - deps = [ - "//packages/core/schematics/utils", - "@npm//@angular-devkit/schematics", - "@npm//@types/node", - "@npm//typescript", - ], -) - -esbuild_no_sourcemaps( - name = "bundle", - entry_point = ":index.ts", - external = [ - "@angular-devkit/*", - "typescript", - ], - format = "cjs", - platform = "node", - deps = [":http-providers"], -) diff --git a/packages/core/schematics/migrations/http-providers/README.md b/packages/core/schematics/migrations/http-providers/README.md deleted file mode 100644 index b16718aa3f622..0000000000000 --- a/packages/core/schematics/migrations/http-providers/README.md +++ /dev/null @@ -1,93 +0,0 @@ -## Replace Http modules from `@angular/common/http` with provider functions - -`HttpClientModule`, `HttpClientXsrfModule`, `HttpClientJsonpModule` are deprecated in favor of `provideHttpClient` and its options. -`HttpClientTestingModule` is deprecated in favor or `provideHttpClientTesting()` - -This migration updates any `@NgModule`, `@Component`, `@Directive` that imports those modules. - -### Http Modules - -#### Before -```ts - -import { HttpClientModule, HttpClientJsonpModule, HttpClientXsrfModule } from '@angular/common/http'; - -@NgModule({ - imports: [CommonModule, HttpClientModule, HttpClientJsonpModule, HttpClientXsrfModule] -}) -export class AppModule {} -``` - -#### After -```ts -import { provideHttpClient, withJsonpSupport, withXsrfConfiguration } from '@angular/common/http'; - -@NgModule({ - imports: [CommonModule], - providers: [provideHttpClient(withJsonpSupport(), withXsrfConfiguration())] -}) -export class AppModule {} -``` - -### Testing - -#### Before - -```ts -import { HttpClientTestingModule } from '@angular/common/http/testing'; - -describe('some test', () => { - - it('...', () => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule] - }) - }) -}) -``` - -#### After - -```ts -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; - -describe('some test', () => { - - it('...', () => { - TestBed.configureTestingModule({ - providers: [provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()] - }) - }) -}) -``` - -#### Before - -```ts -import { HttpClientTesting } from '@angular/common/http'; - -describe('some test', () => { - - it('...', () => { - TestBed.configureTestingModule({ - imports: [HttpClientTesting], - }) - }) -}); -``` - -#### After - -```ts -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; - -describe('some test', () => { - - it('...', () => { - TestBed.configureTestingModule({ - providers: [provideHttpClient(withInterceptorsFromDi())] - }) - }) -}) -``` diff --git a/packages/core/schematics/migrations/http-providers/index.ts b/packages/core/schematics/migrations/http-providers/index.ts deleted file mode 100644 index 91c81433728c6..0000000000000 --- a/packages/core/schematics/migrations/http-providers/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @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.io/license - */ -import {Rule, SchematicsException, Tree, UpdateRecorder} from '@angular-devkit/schematics'; -import {relative} from 'path'; - -import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths'; -import {canMigrateFile, createMigrationProgram} from '../../utils/typescript/compiler_host'; - -import {migrateFile} from './utils'; - -export default function (): Rule { - return async (tree: Tree) => { - const {buildPaths, testPaths} = await getProjectTsConfigPaths(tree); - const basePath = process.cwd(); - const allPaths = [...buildPaths, ...testPaths]; - - if (!allPaths.length) { - throw new SchematicsException( - 'Could not find any tsconfig file. Cannot run the http providers migration.', - ); - } - - for (const tsconfigPath of allPaths) { - runMigration(tree, tsconfigPath, basePath); - } - }; -} - -function runMigration(tree: Tree, tsconfigPath: string, basePath: string) { - const program = createMigrationProgram(tree, tsconfigPath, basePath); - const sourceFiles = program - .getSourceFiles() - .filter((sourceFile) => canMigrateFile(basePath, sourceFile, program)); - - for (const sourceFile of sourceFiles) { - let update: UpdateRecorder | null = null; - - const rewriter = (startPos: number, width: number, text: string | null) => { - if (update === null) { - // Lazily initialize update, because most files will not require migration. - update = tree.beginUpdate(relative(basePath, sourceFile.fileName)); - } - update.remove(startPos, width); - if (text !== null) { - update.insertLeft(startPos, text); - } - }; - migrateFile(sourceFile, program.getTypeChecker(), rewriter); - - if (update !== null) { - tree.commitUpdate(update); - } - } -} diff --git a/packages/core/schematics/migrations/http-providers/utils.ts b/packages/core/schematics/migrations/http-providers/utils.ts deleted file mode 100644 index 37fcd1fff9299..0000000000000 --- a/packages/core/schematics/migrations/http-providers/utils.ts +++ /dev/null @@ -1,521 +0,0 @@ -/** - * @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.io/license - */ - -import ts from 'typescript'; - -import {ChangeTracker} from '../../utils/change_tracker'; -import {getAngularDecorators, NgDecorator} from '../../utils/ng_decorators'; -import {getImportSpecifiers, getNamedImports} from '../../utils/typescript/imports'; - -const HTTP_CLIENT_MODULE = 'HttpClientModule'; -const HTTP_CLIENT_XSRF_MODULE = 'HttpClientXsrfModule'; -const HTTP_CLIENT_JSONP_MODULE = 'HttpClientJsonpModule'; -const HTTP_CLIENT_TESTING_MODULE = 'HttpClientTestingModule'; -const WITH_INTERCEPTORS_FROM_DI = 'withInterceptorsFromDi'; -const WITH_JSONP_SUPPORT = 'withJsonpSupport'; -const WITH_NOXSRF_PROTECTION = 'withNoXsrfProtection'; -const WITH_XSRF_CONFIGURATION = 'withXsrfConfiguration'; -const PROVIDE_HTTP_CLIENT = 'provideHttpClient'; -const PROVIDE_HTTP_CLIENT_TESTING = 'provideHttpClientTesting'; - -const COMMON_HTTP = '@angular/common/http'; -const COMMON_HTTP_TESTING = '@angular/common/http/testing'; - -const HTTP_MODULES = new Set([ - HTTP_CLIENT_MODULE, - HTTP_CLIENT_XSRF_MODULE, - HTTP_CLIENT_JSONP_MODULE, -]); -const HTTP_TESTING_MODULES = new Set([HTTP_CLIENT_TESTING_MODULE]); - -export type RewriteFn = (startPos: number, width: number, text: string) => void; - -export function migrateFile( - sourceFile: ts.SourceFile, - typeChecker: ts.TypeChecker, - rewriteFn: RewriteFn, -) { - const changeTracker = new ChangeTracker(ts.createPrinter()); - const addedImports = new Map>([ - [COMMON_HTTP, new Set()], - [COMMON_HTTP_TESTING, new Set()], - ]); - - const commonHttpIdentifiers = new Set( - getImportSpecifiers(sourceFile, COMMON_HTTP, [...HTTP_MODULES]).map((specifier) => - specifier.getText(), - ), - ); - const commonHttpTestingIdentifiers = new Set( - getImportSpecifiers(sourceFile, COMMON_HTTP_TESTING, [...HTTP_TESTING_MODULES]).map( - (specifier) => specifier.getText(), - ), - ); - - ts.forEachChild(sourceFile, function visit(node: ts.Node) { - ts.forEachChild(node, visit); - - if (ts.isClassDeclaration(node)) { - const decorators = getAngularDecorators(typeChecker, ts.getDecorators(node) || []); - decorators.forEach((decorator) => { - migrateDecorator( - decorator, - commonHttpIdentifiers, - commonHttpTestingIdentifiers, - addedImports, - changeTracker, - sourceFile, - ); - }); - } - - migrateTestingModuleImports( - node, - commonHttpIdentifiers, - commonHttpTestingIdentifiers, - addedImports, - changeTracker, - ); - }); - - // Imports are for the whole file - // We handle them separately - - // Remove the HttpModules imports from common/http - const commonHttpImports = getNamedImports(sourceFile, COMMON_HTTP); - if (commonHttpImports) { - const symbolImportsToRemove = getImportSpecifiers(sourceFile, COMMON_HTTP, [...HTTP_MODULES]); - - const newImports = ts.factory.updateNamedImports(commonHttpImports, [ - ...commonHttpImports.elements.filter((current) => !symbolImportsToRemove.includes(current)), - ...[...(addedImports.get(COMMON_HTTP) ?? [])].map((entry) => { - return ts.factory.createImportSpecifier( - false, - undefined, - ts.factory.createIdentifier(entry), - ); - }), - ]); - changeTracker.replaceNode(commonHttpImports, newImports); - } - // If there are no imports for common/http, and we need to add some - else if (addedImports.get(COMMON_HTTP)?.size) { - // Then we add a new import statement for common/http - addedImports.get(COMMON_HTTP)?.forEach((entry) => { - changeTracker.addImport(sourceFile, entry, COMMON_HTTP); - }); - } - - // Remove the HttpModules imports from common/http/testing - const commonHttpTestingImports = getNamedImports(sourceFile, COMMON_HTTP_TESTING); - if (commonHttpTestingImports) { - const symbolImportsToRemove = getImportSpecifiers(sourceFile, COMMON_HTTP_TESTING, [ - ...HTTP_TESTING_MODULES, - ]); - - const newHttpTestingImports = ts.factory.updateNamedImports(commonHttpTestingImports, [ - ...commonHttpTestingImports.elements.filter( - (current) => !symbolImportsToRemove.includes(current), - ), - ...[...(addedImports.get(COMMON_HTTP_TESTING) ?? [])].map((entry) => { - return ts.factory.createImportSpecifier( - false, - undefined, - ts.factory.createIdentifier(entry), - ); - }), - ]); - changeTracker.replaceNode(commonHttpTestingImports, newHttpTestingImports); - } - - // Writing the changes - for (const changesInFile of changeTracker.recordChanges().values()) { - for (const change of changesInFile) { - rewriteFn(change.start, change.removeLength ?? 0, change.text); - } - } -} - -function migrateDecorator( - decorator: NgDecorator, - commonHttpIdentifiers: Set, - commonHttpTestingIdentifiers: Set, - addedImports: Map>, - changeTracker: ChangeTracker, - sourceFile: ts.SourceFile, -) { - // Only @NgModule and @Component support `imports`. - // Also skip decorators with no arguments. - if ( - (decorator.name !== 'NgModule' && decorator.name !== 'Component') || - decorator.node.expression.arguments.length < 1 - ) { - return; - } - - // Does the decorator have any imports? - const metadata = decorator.node.expression.arguments[0]; - if (!ts.isObjectLiteralExpression(metadata)) { - return; - } - - const moduleImports = getImportsProp(metadata); - if (!moduleImports) { - return; - } - - // Does the decorator import any of the HTTP modules? - const importedModules = getImportedHttpModules( - moduleImports, - commonHttpIdentifiers, - commonHttpTestingIdentifiers, - ); - if (!importedModules) { - return; - } - - // HttpClient imported in component is actually a mistake - // Schematics will be no-op but add a TODO - const isComponent = decorator.name === 'Component'; - if (isComponent && importedModules.client) { - const httpClientModuleIdentifier = importedModules.client; - const commentText = - '\n// TODO: `HttpClientModule` should not be imported into a component directly.\n' + - '// Please refactor the code to add `provideHttpClient()` call to the provider list in the\n' + - '// application bootstrap logic and remove the `HttpClientModule` import from this component.\n'; - ts.addSyntheticLeadingComment( - httpClientModuleIdentifier, - ts.SyntaxKind.SingleLineCommentTrivia, - commentText, - true, - ); - changeTracker.insertText(sourceFile, httpClientModuleIdentifier.getStart(), commentText); - return; - } - - const addedProviders = new Set(); - - // Handle the different imported Http modules - const commonHttpAddedImports = addedImports.get(COMMON_HTTP); - commonHttpAddedImports?.add(PROVIDE_HTTP_CLIENT); - if (importedModules.client || importedModules.clientTesting) { - commonHttpAddedImports?.add(WITH_INTERCEPTORS_FROM_DI); - addedProviders.add(createCallExpression(WITH_INTERCEPTORS_FROM_DI)); - } - if (importedModules.clientJsonp) { - commonHttpAddedImports?.add(WITH_JSONP_SUPPORT); - addedProviders.add(createCallExpression(WITH_JSONP_SUPPORT)); - } - if (importedModules.xsrf) { - // HttpClientXsrfModule is the only module with Class methods. - // They correspond to different provider functions - if (importedModules.xsrfOptions === 'disable') { - commonHttpAddedImports?.add(WITH_NOXSRF_PROTECTION); - addedProviders.add(createCallExpression(WITH_NOXSRF_PROTECTION)); - } else { - commonHttpAddedImports?.add(WITH_XSRF_CONFIGURATION); - addedProviders.add( - createCallExpression( - WITH_XSRF_CONFIGURATION, - importedModules.xsrfOptions?.options ? [importedModules.xsrfOptions.options] : [], - ), - ); - } - } - - // Removing the imported Http modules from the imports list - const newImports = ts.factory.createArrayLiteralExpression([ - ...moduleImports.elements.filter( - (item) => - item !== importedModules.client && - item !== importedModules.clientJsonp && - item !== importedModules.xsrf && - item !== importedModules.clientTesting, - ), - ]); - - // Adding the new providers - const providers = getProvidersFromLiteralExpr(metadata); - - // handle the HttpClientTestingModule - let provideHttpClientTestingExpr: ts.CallExpression | undefined; - if (importedModules.clientTesting) { - const commonHttpTestingAddedImports = addedImports.get(COMMON_HTTP_TESTING); - commonHttpTestingAddedImports?.add(PROVIDE_HTTP_CLIENT_TESTING); - provideHttpClientTestingExpr = createCallExpression(PROVIDE_HTTP_CLIENT_TESTING); - } - const provideHttpExpr = createCallExpression(PROVIDE_HTTP_CLIENT, [...addedProviders]); - const providersToAppend = provideHttpClientTestingExpr - ? [provideHttpExpr, provideHttpClientTestingExpr] - : [provideHttpExpr]; - - let newProviders: ts.ArrayLiteralExpression; - if (!providers) { - // No existing providers, we add a property to the literal - newProviders = ts.factory.createArrayLiteralExpression(providersToAppend); - } else { - // We add the provider to the existing provider array - newProviders = ts.factory.updateArrayLiteralExpression( - providers, - ts.factory.createNodeArray( - [...providers.elements, ...providersToAppend], - providers.elements.hasTrailingComma, - ), - ); - } - - // Replacing the existing decorator with the new one (with the new imports and providers) - const newDecoratorArgs = ts.factory.createObjectLiteralExpression([ - ...metadata.properties.filter( - (property) => - property.name?.getText() !== 'imports' && property.name?.getText() !== 'providers', - ), - ts.factory.createPropertyAssignment('imports', newImports), - ts.factory.createPropertyAssignment('providers', newProviders), - ]); - changeTracker.replaceNode(metadata, newDecoratorArgs); -} - -function migrateTestingModuleImports( - node: ts.Node, - commonHttpIdentifiers: Set, - commonHttpTestingIdentifiers: Set, - addedImports: Map>, - changeTracker: ChangeTracker, -) { - // Look for calls to `TestBed.configureTestingModule` with at least one argument. - // TODO: this won't work if `TestBed` is aliased or type cast. - if ( - !ts.isCallExpression(node) || - node.arguments.length < 1 || - !ts.isPropertyAccessExpression(node.expression) || - !ts.isIdentifier(node.expression.expression) || - node.expression.expression.text !== 'TestBed' || - node.expression.name.text !== 'configureTestingModule' - ) { - return; - } - - // Do we have any arguments for configureTestingModule ? - const configureTestingModuleArgs = node.arguments[0]; - if (!ts.isObjectLiteralExpression(configureTestingModuleArgs)) { - return; - } - - // Do we have an imports property with an array ? - const importsArray = getImportsProp(configureTestingModuleArgs); - if (!importsArray) { - return; - } - - const commonHttpAddedImports = addedImports.get(COMMON_HTTP); - - // Does the imports array contain the HttpClientModule? - const httpClient = importsArray.elements.find((elt) => elt.getText() === HTTP_CLIENT_MODULE); - if (httpClient && commonHttpIdentifiers.has(HTTP_CLIENT_MODULE)) { - // We add the imports for provideHttpClient(withInterceptorsFromDi()) - commonHttpAddedImports?.add(PROVIDE_HTTP_CLIENT); - commonHttpAddedImports?.add(WITH_INTERCEPTORS_FROM_DI); - - const newImports = ts.factory.createArrayLiteralExpression([ - ...importsArray.elements.filter((item) => item !== httpClient), - ]); - - const provideHttpClient = createCallExpression(PROVIDE_HTTP_CLIENT, [ - createCallExpression(WITH_INTERCEPTORS_FROM_DI), - ]); - - // Adding the new provider - const providers = getProvidersFromLiteralExpr(configureTestingModuleArgs); - - let newProviders: ts.ArrayLiteralExpression; - if (!providers) { - // No existing providers, we add a property to the literal - newProviders = ts.factory.createArrayLiteralExpression([provideHttpClient]); - } else { - // We add the provider to the existing provider array - newProviders = ts.factory.updateArrayLiteralExpression( - providers, - ts.factory.createNodeArray( - [...providers.elements, provideHttpClient], - providers.elements.hasTrailingComma, - ), - ); - } - - // Replacing the existing configuration with the new one (with the new imports and providers) - const newTestingModuleArgs = updateTestBedConfiguration( - configureTestingModuleArgs, - newImports, - newProviders, - ); - changeTracker.replaceNode(configureTestingModuleArgs, newTestingModuleArgs); - } - - // Does the imports array contain the HttpClientTestingModule? - const httpClientTesting = importsArray.elements.find( - (elt) => elt.getText() === HTTP_CLIENT_TESTING_MODULE, - ); - if (httpClientTesting && commonHttpTestingIdentifiers.has(HTTP_CLIENT_TESTING_MODULE)) { - // We add the imports for provideHttpClient(withInterceptorsFromDi()) and provideHttpClientTesting() - commonHttpAddedImports?.add(PROVIDE_HTTP_CLIENT); - commonHttpAddedImports?.add(WITH_INTERCEPTORS_FROM_DI); - addedImports.get(COMMON_HTTP_TESTING)?.add(PROVIDE_HTTP_CLIENT_TESTING); - - const newImports = ts.factory.createArrayLiteralExpression([ - ...importsArray.elements.filter((item) => item !== httpClientTesting), - ]); - - const provideHttpClient = createCallExpression(PROVIDE_HTTP_CLIENT, [ - createCallExpression(WITH_INTERCEPTORS_FROM_DI), - ]); - const provideHttpClientTesting = createCallExpression(PROVIDE_HTTP_CLIENT_TESTING); - - // Adding the new providers - const providers = getProvidersFromLiteralExpr(configureTestingModuleArgs); - - let newProviders: ts.ArrayLiteralExpression; - if (!providers) { - // No existing providers, we add a property to the literal - newProviders = ts.factory.createArrayLiteralExpression([ - provideHttpClient, - provideHttpClientTesting, - ]); - } else { - // We add the provider to the existing provider array - newProviders = ts.factory.updateArrayLiteralExpression( - providers, - ts.factory.createNodeArray( - [...providers.elements, provideHttpClient, provideHttpClientTesting], - providers.elements.hasTrailingComma, - ), - ); - } - - // Replacing the existing configuration with the new one (with the new imports and providers) - const newTestingModuleArgs = updateTestBedConfiguration( - configureTestingModuleArgs, - newImports, - newProviders, - ); - changeTracker.replaceNode(configureTestingModuleArgs, newTestingModuleArgs); - } -} - -function getImportsProp(literal: ts.ObjectLiteralExpression) { - const properties = literal.properties; - const importProp = properties.find((property) => property.name?.getText() === 'imports'); - if (!importProp || !ts.hasOnlyExpressionInitializer(importProp)) { - return null; - } - - if (ts.isArrayLiteralExpression(importProp.initializer)) { - return importProp.initializer; - } - - return null; -} - -function getProvidersFromLiteralExpr(literal: ts.ObjectLiteralExpression) { - const properties = literal.properties; - const providersProp = properties.find((property) => property.name?.getText() === 'providers'); - if (!providersProp || !ts.hasOnlyExpressionInitializer(providersProp)) { - return null; - } - - if (ts.isArrayLiteralExpression(providersProp.initializer)) { - return providersProp.initializer; - } - - return null; -} - -function getImportedHttpModules( - imports: ts.ArrayLiteralExpression, - commonHttpIdentifiers: Set, - commonHttpTestingIdentifiers: Set, -) { - let client: ts.Identifier | ts.CallExpression | null = null; - let clientJsonp: ts.Identifier | ts.CallExpression | null = null; - let xsrf: ts.Identifier | ts.CallExpression | null = null; - let clientTesting: ts.Identifier | ts.CallExpression | null = null; - - // represents respectively: - // HttpClientXsrfModule.disable() - // HttpClientXsrfModule.withOptions(options) - // base HttpClientXsrfModule - let xsrfOptions: 'disable' | {options: ts.Expression} | null = null; - - // Handling the http modules from @angular/common/http and the http testing module from @angular/common/http/testing - // and skipping the rest - for (const item of imports.elements) { - if (ts.isIdentifier(item)) { - const moduleName = item.getText(); - - // We only care about the modules from @angular/common/http and @angular/common/http/testing - if (!commonHttpIdentifiers.has(moduleName) && !commonHttpTestingIdentifiers.has(moduleName)) { - continue; - } - - if (moduleName === HTTP_CLIENT_MODULE) { - client = item; - } else if (moduleName === HTTP_CLIENT_JSONP_MODULE) { - clientJsonp = item; - } else if (moduleName === HTTP_CLIENT_XSRF_MODULE) { - xsrf = item; - } else if (moduleName === HTTP_CLIENT_TESTING_MODULE) { - clientTesting = item; - } - } else if (ts.isCallExpression(item) && ts.isPropertyAccessExpression(item.expression)) { - const moduleName = item.expression.expression.getText(); - - // We only care about the modules from @angular/common/http - if (!commonHttpIdentifiers.has(moduleName)) { - continue; - } - - if (moduleName === HTTP_CLIENT_XSRF_MODULE) { - xsrf = item; - if (item.expression.getText().includes('withOptions') && item.arguments.length === 1) { - xsrfOptions = {options: item.arguments[0]}; - } else if (item.expression.getText().includes('disable')) { - xsrfOptions = 'disable'; - } - } - } - } - - if (client !== null || clientJsonp !== null || xsrf !== null || clientTesting !== null) { - return {client, clientJsonp, xsrf, xsrfOptions, clientTesting}; - } - - return null; -} - -function createCallExpression(functionName: string, args: ts.Expression[] = []) { - return ts.factory.createCallExpression( - ts.factory.createIdentifier(functionName), - undefined, - args, - ); -} - -function updateTestBedConfiguration( - configureTestingModuleArgs: ts.ObjectLiteralExpression, - newImports: ts.ArrayLiteralExpression, - newProviders: ts.ArrayLiteralExpression, -): ts.ObjectLiteralExpression { - return ts.factory.updateObjectLiteralExpression(configureTestingModuleArgs, [ - ...configureTestingModuleArgs.properties.filter( - (property) => - property.name?.getText() !== 'imports' && property.name?.getText() !== 'providers', - ), - ts.factory.createPropertyAssignment('imports', newImports), - ts.factory.createPropertyAssignment('providers', newProviders), - ]); -} diff --git a/packages/core/schematics/migrations/invalid-two-way-bindings/BUILD.bazel b/packages/core/schematics/migrations/invalid-two-way-bindings/BUILD.bazel deleted file mode 100644 index 58e23012a72d1..0000000000000 --- a/packages/core/schematics/migrations/invalid-two-way-bindings/BUILD.bazel +++ /dev/null @@ -1,34 +0,0 @@ -load("//tools:defaults.bzl", "esbuild_no_sourcemaps", "ts_library") - -package( - default_visibility = [ - "//packages/core/schematics:__pkg__", - "//packages/core/schematics/migrations/google3:__pkg__", - "//packages/core/schematics/test:__pkg__", - ], -) - -ts_library( - name = "invalid-two-way-bindings", - srcs = glob(["**/*.ts"]), - tsconfig = "//packages/core/schematics:tsconfig.json", - deps = [ - "//packages/compiler", - "//packages/core/schematics/utils", - "@npm//@angular-devkit/schematics", - "@npm//@types/node", - "@npm//typescript", - ], -) - -esbuild_no_sourcemaps( - name = "bundle", - entry_point = ":index.ts", - external = [ - "@angular-devkit/*", - "typescript", - ], - format = "cjs", - platform = "node", - deps = [":invalid-two-way-bindings"], -) diff --git a/packages/core/schematics/migrations/invalid-two-way-bindings/README.md b/packages/core/schematics/migrations/invalid-two-way-bindings/README.md deleted file mode 100644 index cc7e074340c52..0000000000000 --- a/packages/core/schematics/migrations/invalid-two-way-bindings/README.md +++ /dev/null @@ -1,38 +0,0 @@ -## Invalid two-way bindings migration - -Due to a quirk in the template parser, Angular previously allowed some unassignable expressions -to be passed into two-way bindings which may produce incorrect results. This migration will -replace the invalid two-way bindings with their input/output pair while preserving the original -behavior. Note that the migrated expression may not be the original intent of the code as it was -written, but they match what the Angular runtime would've executed. - -The invalid bindings will become errors in a future version of Angular. - -Some examples of invalid expressions include: -* Binary expressions like `[(ngModel)]="a || b"`. Previously Angular would append `= $event` to -the right-hand-side of the expression (e.g. `(ngModelChange)="a || (b = $event)"`). -* Unary expressions like `[(ngModel)]="!a"` which Angular would wrap in a parentheses and execute -(e.g. `(ngModelChange)="!(a = $event)"`). -* Conditional expressions like `[(ngModel)]="a ? b : c"` where Angular would add `= $event` to -the false case, e.g. `(ngModelChange)="a ? b : c = $event"`. - -#### Before -```ts -import {Component} from '@angular/core'; - -@Component({ - template: `` -}) -export class MyComp {} -``` - - -#### After -```ts -import {Component} from '@angular/core'; - -@Component({ - template: `` -}) -export class MyComp {} -``` diff --git a/packages/core/schematics/migrations/invalid-two-way-bindings/analysis.ts b/packages/core/schematics/migrations/invalid-two-way-bindings/analysis.ts deleted file mode 100644 index e8723989d64e0..0000000000000 --- a/packages/core/schematics/migrations/invalid-two-way-bindings/analysis.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * @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.io/license - */ - -import {dirname, join} from 'path'; -import ts from 'typescript'; - -/** - * Represents a range of text within a file. Omitting the end - * means that it's until the end of the file. - */ -type Range = [start: number, end?: number]; - -/** Represents a file that was analyzed by the migration. */ -export class AnalyzedFile { - private ranges: Range[] = []; - - /** Returns the ranges in the order in which they should be migrated. */ - getSortedRanges(): Range[] { - return this.ranges.slice().sort(([aStart], [bStart]) => bStart - aStart); - } - - /** - * Adds a text range to an `AnalyzedFile`. - * @param path Path of the file. - * @param analyzedFiles Map keeping track of all the analyzed files. - * @param range Range to be added. - */ - static addRange(path: string, analyzedFiles: Map, range: Range): void { - let analysis = analyzedFiles.get(path); - - if (!analysis) { - analysis = new AnalyzedFile(); - analyzedFiles.set(path, analysis); - } - - const duplicate = analysis.ranges.find( - (current) => current[0] === range[0] && current[1] === range[1], - ); - - if (!duplicate) { - analysis.ranges.push(range); - } - } -} - -/** - * Analyzes a source file to find file that need to be migrated and the text ranges within them. - * @param sourceFile File to be analyzed. - * @param analyzedFiles Map in which to store the results. - */ -export function analyze(sourceFile: ts.SourceFile, analyzedFiles: Map) { - forEachClass(sourceFile, (node) => { - // Note: we have a utility to resolve the Angular decorators from a class declaration already. - // We don't use it here, because it requires access to the type checker which makes it more - // time-consuming to run internally. - const decorator = ts.getDecorators(node)?.find((dec) => { - return ( - ts.isCallExpression(dec.expression) && - ts.isIdentifier(dec.expression.expression) && - dec.expression.expression.text === 'Component' - ); - }) as (ts.Decorator & {expression: ts.CallExpression}) | undefined; - - const metadata = - decorator && - decorator.expression.arguments.length > 0 && - ts.isObjectLiteralExpression(decorator.expression.arguments[0]) - ? decorator.expression.arguments[0] - : null; - - if (!metadata) { - return; - } - - for (const prop of metadata.properties) { - // All the properties we care about should have static - // names and be initialized to a static string. - if ( - !ts.isPropertyAssignment(prop) || - !ts.isStringLiteralLike(prop.initializer) || - (!ts.isIdentifier(prop.name) && !ts.isStringLiteralLike(prop.name)) - ) { - continue; - } - - switch (prop.name.text) { - case 'template': - // +1/-1 to exclude the opening/closing characters from the range. - AnalyzedFile.addRange(sourceFile.fileName, analyzedFiles, [ - prop.initializer.getStart() + 1, - prop.initializer.getEnd() - 1, - ]); - break; - - case 'templateUrl': - // Leave the end as undefined which means that the range is until the end of the file. - const path = join(dirname(sourceFile.fileName), prop.initializer.text); - AnalyzedFile.addRange(path, analyzedFiles, [0]); - break; - } - } - }); -} - -/** Executes a callback on each class declaration in a file. */ -function forEachClass(sourceFile: ts.SourceFile, callback: (node: ts.ClassDeclaration) => void) { - sourceFile.forEachChild(function walk(node) { - if (ts.isClassDeclaration(node)) { - callback(node); - } - node.forEachChild(walk); - }); -} diff --git a/packages/core/schematics/migrations/invalid-two-way-bindings/index.ts b/packages/core/schematics/migrations/invalid-two-way-bindings/index.ts deleted file mode 100644 index 799a50aba5678..0000000000000 --- a/packages/core/schematics/migrations/invalid-two-way-bindings/index.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * @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.io/license - */ - -import {Rule, SchematicsException, Tree} from '@angular-devkit/schematics'; -import {relative} from 'path'; - -import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths'; -import {canMigrateFile, createMigrationProgram} from '../../utils/typescript/compiler_host'; - -import {analyze, AnalyzedFile} from './analysis'; -import {migrateTemplate} from './migration'; - -export default function (): Rule { - return async (tree: Tree) => { - const {buildPaths, testPaths} = await getProjectTsConfigPaths(tree); - const basePath = process.cwd(); - const allPaths = [...buildPaths, ...testPaths]; - - if (!allPaths.length) { - throw new SchematicsException( - 'Could not find any tsconfig file. Cannot run the invalid two-way bindings migration.', - ); - } - - for (const tsconfigPath of allPaths) { - runInvalidTwoWayBindingsMigration(tree, tsconfigPath, basePath); - } - }; -} - -function runInvalidTwoWayBindingsMigration(tree: Tree, tsconfigPath: string, basePath: string) { - const program = createMigrationProgram(tree, tsconfigPath, basePath); - const sourceFiles = program - .getSourceFiles() - .filter((sourceFile) => canMigrateFile(basePath, sourceFile, program)); - const analysis = new Map(); - - for (const sourceFile of sourceFiles) { - analyze(sourceFile, analysis); - } - - for (const [path, file] of analysis) { - const ranges = file.getSortedRanges(); - const relativePath = relative(basePath, path); - - // Don't interrupt the entire migration if a file can't be read. - if (!tree.exists(relativePath)) { - continue; - } - - const content = tree.readText(relativePath); - const update = tree.beginUpdate(relativePath); - - for (const [start, end] of ranges) { - const template = content.slice(start, end); - const length = (end ?? content.length) - start; - const migrated = migrateTemplate(template); - - if (migrated !== null) { - update.remove(start, length); - update.insertLeft(start, migrated); - } - } - - tree.commitUpdate(update); - } -} diff --git a/packages/core/schematics/migrations/invalid-two-way-bindings/migration.ts b/packages/core/schematics/migrations/invalid-two-way-bindings/migration.ts deleted file mode 100644 index 79aec36f0fbfe..0000000000000 --- a/packages/core/schematics/migrations/invalid-two-way-bindings/migration.ts +++ /dev/null @@ -1,263 +0,0 @@ -/*! - * @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.io/license - */ - -import { - ASTWithSource, - BindingType, - ParsedEventType, - parseTemplate, - ReadKeyExpr, - ReadPropExpr, - TmplAstBoundAttribute, - TmplAstElement, - TmplAstNode, - TmplAstRecursiveVisitor, - TmplAstTemplate, -} from '@angular/compiler'; -import ts from 'typescript'; - -/** - * Migrates a template to replace the invalid usages of two-way bindings with their long form. - * Returns null if no changes had to be made to the file. - * @param template Template to be migrated. - */ -export function migrateTemplate(template: string): string | null { - // Don't attempt to parse templates that don't contain two-way bindings. - if (!template.includes(')]=')) { - return null; - } - - let rootNodes: TmplAstNode[] | null = null; - - try { - const parsed = parseTemplate(template, '', {allowInvalidAssignmentEvents: true}); - - if (parsed.errors === null) { - rootNodes = parsed.nodes; - } - } catch {} - - // Don't migrate invalid templates. - if (rootNodes === null) { - return null; - } - - const visitor = new InvalidTwoWayBindingCollector(); - const bindings = visitor - .collectInvalidBindings(rootNodes) - .sort((a, b) => b.sourceSpan.start.offset - a.sourceSpan.start.offset); - - if (bindings.length === 0) { - return null; - } - - let result = template; - const printer = ts.createPrinter(); - - for (const binding of bindings) { - const valueText = result.slice(binding.value.sourceSpan.start, binding.value.sourceSpan.end); - const outputText = migrateTwoWayEvent(valueText, binding, printer); - - if (outputText === null) { - continue; - } - - const before = result.slice(0, binding.sourceSpan.start.offset); - const after = result.slice(binding.sourceSpan.end.offset); - const inputText = migrateTwoWayInput(binding, valueText); - result = before + inputText + ' ' + outputText + after; - } - - return result; -} - -/** - * Creates the string for the input side of an invalid two-way bindings. - * @param binding Invalid two-way binding to be migrated. - * @param value String value of the binding. - */ -function migrateTwoWayInput(binding: TmplAstBoundAttribute, value: string): string { - return `[${binding.name}]="${value}"`; -} - -/** - * Creates the string for the output side of an invalid two-way bindings. - * @param binding Invalid two-way binding to be migrated. - * @param value String value of the binding. - */ -function migrateTwoWayEvent( - value: string, - binding: TmplAstBoundAttribute, - printer: ts.Printer, -): string | null { - // Note that we use the TypeScript parser, as opposed to our own, because even though we have - // an expression AST here already, our AST is harder to work with in a migration context. - // To use it here, we would have to solve the following: - // 1. Expose the internal converter that turns it from an event AST to an output AST. - // 2. The process of converting to an output AST also transforms some expressions - // (e.g. `foo.bar` becomes `ctx.foo.bar`). We would have to strip away those transformations here - // which introduces room for mistakes. - // 3. We'd still need a way to convert the output AST back into a string. We have such a utility - // for JIT compilation, but it also includes JIT-specific logic we might not want. - // Given these issues and the fact that the kinds of expressions we're migrating is fairly narrow, - // we can get away with using the TypeScript AST instead. - const sourceFile = ts.createSourceFile('temp.ts', value, ts.ScriptTarget.Latest); - const expression = - sourceFile.statements.length === 1 && ts.isExpressionStatement(sourceFile.statements[0]) - ? sourceFile.statements[0].expression - : null; - - if (expression === null) { - return null; - } - - let migrated: ts.Expression | null = null; - - // Historically the expression parser was handling two-way events by appending `=$event` - // to the raw string before attempting to parse it. This has led to bugs over the years (see - // #37809) and to unintentionally supporting unassignable events in the two-way binding. The - // logic below aims to emulate the old behavior. Note that the generated code doesn't necessarily - // make sense based on what the user wrote, for example the event binding for `[(value)]="a ? b : - // c"` would produce `ctx.a ? ctx.b : ctx.c = $event`. We aim to reproduce what the parser used to - // generate before #54154. - if (ts.isBinaryExpression(expression) && isReadExpression(expression.right)) { - // `a && b` -> `a && (b = $event)` - migrated = ts.factory.updateBinaryExpression( - expression, - expression.left, - expression.operatorToken, - wrapInEventAssignment(expression.right), - ); - } else if (ts.isConditionalExpression(expression) && isReadExpression(expression.whenFalse)) { - // `a ? b : c` -> `a ? b : c = $event` - migrated = ts.factory.updateConditionalExpression( - expression, - expression.condition, - expression.questionToken, - expression.whenTrue, - expression.colonToken, - wrapInEventAssignment(expression.whenFalse), - ); - } else if (isPrefixNot(expression)) { - // `!!a` -> `a = $event` - let innerExpression = expression.operand; - while (true) { - if (isPrefixNot(innerExpression)) { - innerExpression = innerExpression.operand; - } else { - if (isReadExpression(innerExpression)) { - migrated = wrapInEventAssignment(innerExpression); - } - - break; - } - } - } - - if (migrated === null) { - return null; - } - - const newValue = printer.printNode(ts.EmitHint.Expression, migrated, sourceFile); - return `(${binding.name}Change)="${newValue}"`; -} - -/** Wraps an expression in an assignment to `$event`, e.g. `foo.bar = $event`. */ -function wrapInEventAssignment(node: ts.Expression): ts.Expression { - return ts.factory.createBinaryExpression( - node, - ts.factory.createToken(ts.SyntaxKind.EqualsToken), - ts.factory.createIdentifier('$event'), - ); -} - -/** - * Checks whether an expression is a valid read expression. Note that identifiers - * are considered read expressions in Angular templates as well. - */ -function isReadExpression( - node: ts.Expression, -): node is ts.Identifier | ts.PropertyAccessExpression | ts.ElementAccessExpression { - return ( - ts.isIdentifier(node) || - ts.isPropertyAccessExpression(node) || - ts.isElementAccessExpression(node) - ); -} - -/** Checks whether an expression is in the form of `!x`. */ -function isPrefixNot(node: ts.Expression): node is ts.PrefixUnaryExpression { - return ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken; -} - -/** Traverses a template AST and collects any invalid two-way bindings. */ -class InvalidTwoWayBindingCollector extends TmplAstRecursiveVisitor { - private invalidBindings: TmplAstBoundAttribute[] | null = null; - - collectInvalidBindings(rootNodes: TmplAstNode[]): TmplAstBoundAttribute[] { - const result = (this.invalidBindings = []); - rootNodes.forEach((node) => node.visit(this)); - this.invalidBindings = null; - return result; - } - - override visitElement(element: TmplAstElement): void { - this.visitNodeWithBindings(element); - super.visitElement(element); - } - - override visitTemplate(template: TmplAstTemplate): void { - this.visitNodeWithBindings(template); - super.visitTemplate(template); - } - - private visitNodeWithBindings(node: TmplAstElement | TmplAstTemplate) { - const seenOneWayBindings = new Set(); - - // Collect all of the regular event and input binding - // names so we can easily check for their presence. - for (const output of node.outputs) { - if (output.type === ParsedEventType.Regular) { - seenOneWayBindings.add(output.name); - } - } - - for (const input of node.inputs) { - if (input.type === BindingType.Property) { - seenOneWayBindings.add(input.name); - } - } - - // Make a second pass only over the two-way bindings. - for (const input of node.inputs) { - // Skip over non-two-way bindings or two-way bindings where the user is also binding - // to the input/output side. We can't migrate the latter, because we may end up converting - // something like `[(ngModel)]="invalid" (ngModelChange)="foo()"` to - // `[ngModel]="invalid" (ngModelChange)="invalid = $event" (ngModelChange)="foo()"` which - // would break the app. - if ( - input.type !== BindingType.TwoWay || - seenOneWayBindings.has(input.name) || - seenOneWayBindings.has(input.name + 'Change') - ) { - continue; - } - - let value = input.value; - - if (value instanceof ASTWithSource) { - value = value.ast; - } - - // The only supported expression types are property reads and keyed reads. - if (!(value instanceof ReadPropExpr) && !(value instanceof ReadKeyExpr)) { - this.invalidBindings!.push(input); - } - } - } -} diff --git a/packages/core/schematics/test/BUILD.bazel b/packages/core/schematics/test/BUILD.bazel index d0b17bdcd8231..004222dfe88c7 100644 --- a/packages/core/schematics/test/BUILD.bazel +++ b/packages/core/schematics/test/BUILD.bazel @@ -19,12 +19,6 @@ jasmine_node_test( data = [ "//packages/core/schematics:collection.json", "//packages/core/schematics:migrations.json", - "//packages/core/schematics/migrations/after-render-phase", - "//packages/core/schematics/migrations/after-render-phase:bundle", - "//packages/core/schematics/migrations/http-providers", - "//packages/core/schematics/migrations/http-providers:bundle", - "//packages/core/schematics/migrations/invalid-two-way-bindings", - "//packages/core/schematics/migrations/invalid-two-way-bindings:bundle", "//packages/core/schematics/ng-generate/control-flow-migration", "//packages/core/schematics/ng-generate/control-flow-migration:bundle", "//packages/core/schematics/ng-generate/control-flow-migration:static_files", diff --git a/packages/core/schematics/test/after_render_phase_spec.ts b/packages/core/schematics/test/after_render_phase_spec.ts deleted file mode 100644 index ad4b7532a9759..0000000000000 --- a/packages/core/schematics/test/after_render_phase_spec.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * @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.io/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'; -import {runfiles} from '@bazel/runfiles'; -import shx from 'shelljs'; - -describe('afterRender phase migration', () => { - let runner: SchematicTestRunner; - let host: TempScopedNodeJsSyncHost; - let tree: UnitTestTree; - let tmpDirPath: string; - let previousWorkingDir: string; - - function writeFile(filePath: string, contents: string) { - host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents)); - } - - function runMigration() { - return runner.runSchematic('migration-after-render-phase', {}, tree); - } - - beforeEach(() => { - runner = new SchematicTestRunner('test', runfiles.resolvePackageRelative('../migrations.json')); - host = new TempScopedNodeJsSyncHost(); - tree = new UnitTestTree(new HostTree(host)); - - writeFile( - '/tsconfig.json', - JSON.stringify({ - compilerOptions: { - lib: ['es2015'], - strictNullChecks: true, - }, - }), - ); - - writeFile( - '/angular.json', - JSON.stringify({ - version: 1, - projects: {t: {root: '', architect: {build: {options: {tsConfig: './tsconfig.json'}}}}}, - }), - ); - - previousWorkingDir = shx.pwd(); - tmpDirPath = getSystemPath(host.root); - - // Switch into the temporary directory path. This allows us to run - // the schematic against our custom unit test tree. - shx.cd(tmpDirPath); - }); - - it('should update afterRender phase flag', async () => { - writeFile( - '/index.ts', - ` - import { AfterRenderPhase, Directive, afterRender } from '@angular/core'; - - @Directive({ - selector: '[someDirective]' - }) - export class SomeDirective { - constructor() { - afterRender(() => { - console.log('read'); - }, {phase: AfterRenderPhase.Read}); - } - }`, - ); - - await runMigration(); - - const content = tree.readContent('/index.ts').replace(/\s+/g, ' '); - expect(content).not.toContain('AfterRenderPhase'); - expect(content).toContain("import { Directive, afterRender } from '@angular/core';"); - expect(content).toContain(`afterRender({ read: () => { console.log('read'); } }, );`); - }); - - it('should update afterNextRender phase flag', async () => { - writeFile( - '/index.ts', - ` - import { AfterRenderPhase, Directive, afterNextRender } from '@angular/core'; - - @Directive({ - selector: '[someDirective]' - }) - export class SomeDirective { - constructor() { - afterNextRender(() => { - console.log('earlyRead'); - }, {phase: AfterRenderPhase.EarlyRead}); - } - }`, - ); - - await runMigration(); - - const content = tree.readContent('/index.ts').replace(/\s+/g, ' '); - expect(content).not.toContain('AfterRenderPhase'); - expect(content).toContain("import { Directive, afterNextRender } from '@angular/core';"); - expect(content).toContain( - `afterNextRender({ earlyRead: () => { console.log('earlyRead'); } }, );`, - ); - }); - - it('should not update calls that do not specify phase flag', async () => { - const originalContent = ` - import { Directive, Injector, afterRender, afterNextRender, inject } from '@angular/core'; - - @Directive({ - selector: '[someDirective]' - }) - export class SomeDirective { - injector = inject(Injector); - - constructor() { - afterRender(() => { - console.log('default phase'); - }); - afterNextRender(() => { - console.log('default phase'); - }); - afterRender(() => { - console.log('default phase'); - }, {injector: this.injector}); - afterNextRender(() => { - console.log('default phase'); - }, {injector: this.injector}); - } - }`; - writeFile('/index.ts', originalContent); - - await runMigration(); - - const content = tree.readContent('/index.ts').replace(/\s+/g, ' '); - expect(content).toEqual(originalContent.replace(/\s+/g, ' ')); - }); - - it('should not change options other than phase', async () => { - writeFile( - '/index.ts', - ` - import { Directive, Injector, afterRender, AfterRenderPhase, inject } from '@angular/core'; - - @Directive({ - selector: '[someDirective]' - }) - export class SomeDirective { - injector = inject(Injector); - - constructor() { - afterRender(() => { - console.log('earlyRead'); - }, { - phase: AfterRenderPhase.EarlyRead, - injector: this.injector - }); - } - }`, - ); - - await runMigration(); - const content = tree.readContent('/index.ts').replace(/\s+/g, ' '); - expect(content).not.toContain('AfterRenderPhase'); - expect(content).toContain( - "import { Directive, Injector, afterRender, inject } from '@angular/core';", - ); - expect(content).toContain( - `afterRender({ earlyRead: () => { console.log('earlyRead'); } }, { injector: this.injector });`, - ); - }); -}); diff --git a/packages/core/schematics/test/all-migrations.spec.ts b/packages/core/schematics/test/all-migrations.spec.ts index ce16f1d2f59db..cc93dccb3c872 100644 --- a/packages/core/schematics/test/all-migrations.spec.ts +++ b/packages/core/schematics/test/all-migrations.spec.ts @@ -62,14 +62,16 @@ describe('all migrations', () => { await runner.runSchematic(migrationName, undefined, tree); } - if (!allMigrationSchematics.length) { - throw Error('No migration schematics found.'); + if (allMigrationSchematics.length) { + allMigrationSchematics.forEach((name) => { + describe(name, () => createTests(name)); + }); + } else { + it('should pass', () => { + expect(true).toBe(true); + }); } - allMigrationSchematics.forEach((name) => { - describe(name, () => createTests(name)); - }); - function createTests(migrationName: string) { // Regression test for: https://github.com/angular/angular/issues/36346. it('should not throw if non-existent symbols are imported with rootDirs', async () => { diff --git a/packages/core/schematics/test/http_providers_spec.ts b/packages/core/schematics/test/http_providers_spec.ts deleted file mode 100644 index d6111c0bad3f6..0000000000000 --- a/packages/core/schematics/test/http_providers_spec.ts +++ /dev/null @@ -1,478 +0,0 @@ -/** - * @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.io/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'; -import {runfiles} from '@bazel/runfiles'; -import shx from 'shelljs'; - -describe('Http providers migration', () => { - let runner: SchematicTestRunner; - let host: TempScopedNodeJsSyncHost; - let tree: UnitTestTree; - let tmpDirPath: string; - let previousWorkingDir: string; - - function writeFile(filePath: string, contents: string) { - host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents)); - } - - function runMigration() { - return runner.runSchematic('migration-http-providers', {}, tree); - } - - beforeEach(() => { - runner = new SchematicTestRunner('test', runfiles.resolvePackageRelative('../migrations.json')); - host = new TempScopedNodeJsSyncHost(); - tree = new UnitTestTree(new HostTree(host)); - - writeFile( - '/tsconfig.json', - JSON.stringify({ - compilerOptions: { - lib: ['es2015'], - strictNullChecks: true, - }, - }), - ); - - writeFile( - '/angular.json', - JSON.stringify({ - version: 1, - projects: {t: {root: '', architect: {build: {options: {tsConfig: './tsconfig.json'}}}}}, - }), - ); - - previousWorkingDir = shx.pwd(); - tmpDirPath = getSystemPath(host.root); - - // Switch into the temporary directory path. This allows us to run - // the schematic against our custom unit test tree. - shx.cd(tmpDirPath); - }); - - afterEach(() => { - shx.cd(previousWorkingDir); - shx.rm('-r', tmpDirPath); - }); - - it('should replace HttpClientModule', async () => { - writeFile( - '/index.ts', - ` - import { NgModule } from '@angular/core'; - import { HttpClientModule, HttpClientJsonpModule, HttpClientXsrfModule, HttpTransferCacheOptions } from '@angular/common/http'; - import { CommonModule } from '@angular/common'; - import { AppComponent } from './app.component'; - - @NgModule({ - declarations: [AppComponent], - imports: [ - CommonModule, - HttpClientModule,HttpClientJsonpModule, - RouterModule.forRoot([]), - HttpClientXsrfModule.withOptions({cookieName: 'foobar'}) - ], - }) - export class AppModule {}`, - ); - - await runMigration(); - - const content = tree.readContent('/index.ts'); - expect(content).toContain(`@angular/common/http`); - expect(content).not.toContain(`HttpClientModule`); - expect(content).not.toContain(`HttpClientXsrfModule`); - expect(content).not.toContain(`HttpClientJsonpModule`); - expect(content).toContain(`HttpTransferCacheOptions`); - expect(content).toMatch(/import.*provideHttpClient/); - expect(content).toMatch(/import.*withInterceptorsFromDi/); - expect(content).toMatch(/import.*withJsonpSupport/); - expect(content).toMatch(/import.*withXsrfConfiguration/); - expect(content).toContain( - `provideHttpClient(withInterceptorsFromDi(), withJsonpSupport(), withXsrfConfiguration({ cookieName: 'foobar' }))`, - ); - expect(content).toContain(`RouterModule.forRoot([])`); - expect(content).toContain(`declarations: [AppComponent]`); - }); - - it('should replace HttpClientModule with existing providers ', async () => { - writeFile( - '/index.ts', - ` - import { NgModule } from '@angular/core'; - import { HttpClientModule, HttpClientJsonpModule, HttpClientXsrfModule, HttpTransferCacheOptions } from '@angular/common/http'; - - @NgModule({ - imports: [ - CommonModule, - HttpClientModule, - HttpClientJsonpModule, - RouterModule.forRoot([]), - HttpClientXsrfModule.withOptions({cookieName: 'foobar'}) - ], - providers: [provideConfig({ someConfig: 'foobar'})] - }) - export class AppModule {} - `, - ); - - await runMigration(); - - const content = tree.readContent('/index.ts'); - expect(content).toContain(`@angular/common/http`); - expect(content).not.toContain(`HttpClientModule`); - expect(content).not.toContain(`HttpClientXsrfModule`); - expect(content).not.toContain(`HttpClientJsonpModule`); - expect(content).toContain(`HttpTransferCacheOptions`); - expect(content).toContain(`provideConfig({ someConfig: 'foobar' })`); - expect(content).toContain( - `provideHttpClient(withInterceptorsFromDi(), withJsonpSupport(), withXsrfConfiguration({ cookieName: 'foobar' }))`, - ); - }); - - it('should replace HttpClientModule & HttpClientXsrfModule.disable()', async () => { - writeFile( - '/index.ts', - ` - import { NgModule } from '@angular/core'; - import { HttpClientModule, HttpClientJsonpModule, HttpClientXsrfModule, HttpTransferCacheOptions } from '@angular/common/http'; - - @NgModule({ - imports: [ - CommonModule, - HttpClientModule, - HttpClientJsonpModule, - RouterModule.forRoot([]), - HttpClientXsrfModule.disable() - ], - providers: [provideConfig({ someConfig: 'foobar'})] - }) - export class AppModule {} - `, - ); - - await runMigration(); - - const content = tree.readContent('/index.ts'); - expect(content).toContain(`@angular/common/http`); - expect(content).not.toContain(`HttpClientModule`); - expect(content).not.toContain(`HttpClientXsrfModule`); - expect(content).not.toContain(`HttpClientJsonpModule`); - expect(content).toContain(`HttpTransferCacheOptions`); - expect(content).toContain(`provideConfig({ someConfig: 'foobar' })`); - expect(content).toContain( - `provideHttpClient(withInterceptorsFromDi(), withJsonpSupport(), withNoXsrfProtection())`, - ); - }); - - it('should replace HttpClientModule & base HttpClientXsrfModule', async () => { - writeFile( - '/index.ts', - ` - import { NgModule } from '@angular/core'; - import { HttpClientModule, HttpClientJsonpModule, HttpClientXsrfModule, HttpTransferCacheOptions } from '@angular/common/http'; - - @NgModule({ - imports: [ - CommonModule, - HttpClientModule, - RouterModule.forRoot([]), - HttpClientXsrfModule - ], - providers: [provideConfig({ someConfig: 'foobar'})] - }) - export class AppModule {} - `, - ); - - await runMigration(); - - const content = tree.readContent('/index.ts'); - expect(content).toContain(`@angular/common/http`); - expect(content).not.toContain(`HttpClientModule`); - expect(content).not.toContain(`HttpClientXsrfModule`); - expect(content).not.toContain(`HttpClientJsonpModule`); - expect(content).not.toContain(`withJsonpSupport`); - expect(content).toContain(`HttpTransferCacheOptions`); - expect(content).toContain(`provideConfig({ someConfig: 'foobar' })`); - expect(content).toContain( - `provideHttpClient(withInterceptorsFromDi(), withXsrfConfiguration())`, - ); - }); - - it('should handle a migration with 2 modules in the same file ', async () => { - writeFile( - '/index.ts', - ` - import { NgModule } from '@angular/core'; - import { HttpClientModule, HttpClientJsonpModule, HttpClientXsrfModule, HttpTransferCacheOptions } from '@angular/common/http'; - - @NgModule({ - imports: [CommonModule, HttpClientModule, HttpClientJsonpModule], - providers: [provideConfig({ someConfig: 'foobar'})] - }) - export class AppModule {} - - @NgModule({ - imports: [CommonModule, HttpClientModule, HttpClientXsrfModule.disable()], - providers: [provideConfig({ someConfig: 'foobar'})] - }) - export class AppModule {} - `, - ); - - await runMigration(); - - const content = tree.readContent('/index.ts'); - expect(content).toContain(`@angular/common/http`); - expect(content).not.toContain(`HttpClientModule`); - expect(content).not.toContain(`HttpClientXsrfModule`); - expect(content).not.toContain(`HttpClientJsonpModule`); - expect(content).toContain(`HttpTransferCacheOptions`); - expect(content).toContain(`provideConfig({ someConfig: 'foobar' })`); - expect(content).toContain(`provideHttpClient(withInterceptorsFromDi(), withJsonpSupport())`); - expect(content).toContain( - `provideHttpClient(withInterceptorsFromDi(), withNoXsrfProtection())`, - ); - }); - - it('should handle a migration for a component', async () => { - writeFile( - '/index.ts', - ` - import { Component } from '@angular/core'; - import { HttpClientModule, HttpClientJsonpModule } from '@angular/common/http'; - - @Component({ - template: '', - imports: [HttpClientModule,HttpClientJsonpModule], - }) - export class MyComponent {} - `, - ); - - await runMigration(); - - const content = tree.readContent('/index.ts'); - expect(content).toContain(`@angular/common/http`); - expect(content).toContain(`HttpClientModule`); - expect(content).not.toContain( - `provideHttpClient(withInterceptorsFromDi(), withJsonpSupport())`, - ); - expect(content).toContain('// TODO: `HttpClientModule` should not be imported'); - expect(content).toContain(`template: ''`); - }); - - it('should handle a migration of HttpClientModule in a test', async () => { - writeFile( - '/index.ts', - ` - import { HttpClientModule } from '@angular/common/http'; - - describe('MyComponent', () => { - beforeEach(() => - TestBed.configureTestingModule({ - imports: [HttpClientModule] - }) - ); - }); - `, - ); - - await runMigration(); - - const content = tree.readContent('/index.ts'); - expect(content).not.toContain(`'@angular/common/http/testing'`); - expect(content).toContain(`'@angular/common/http'`); - expect(content).toMatch(/import.*provideHttpClient.*withInterceptorsFromDi.*from/); - expect(content).not.toContain(`HttpClientModule`); - expect(content).toContain(`provideHttpClient(withInterceptorsFromDi())`); - }); - - it('should not migrate HttpClientModule from another package', async () => { - writeFile( - '/index.ts', - ` - import { NgModule } from '@angular/core'; - import { HttpClientModule, HttpClientJsonpModule, HttpClientXsrfModule, HttpTransferCacheOptions } from '@not-angular/common/http'; - - @NgModule({ - imports: [CommonModule,HttpClientModule,HttpClientJsonpModule], - providers: [provideConfig({ someConfig: 'foobar' })] - }) - export class AppModule {} - - @NgModule({ - imports: [CommonModule,HttpClientModule,HttpClientXsrfModule.disable()], - providers: [provideConfig({ someConfig: 'foobar' })] - }) - export class AppModule {} - `, - ); - - await runMigration(); - - const content = tree.readContent('/index.ts'); - expect(content).toContain(`@not-angular/common/http`); - expect(content).toContain(`HttpClientModule`); - expect(content).toContain(`HttpClientXsrfModule`); - expect(content).toContain(`HttpClientJsonpModule`); - expect(content).toContain(`HttpTransferCacheOptions`); - expect(content).toContain(`provideConfig({ someConfig: 'foobar' })`); - expect(content).not.toContain( - `provideHttpClient(withInterceptorsFromDi(), withJsonpSupport())`, - ); - expect(content).not.toContain( - `provideHttpClient(withInterceptorsFromDi(), withNoXsrfProtection())`, - ); - }); - - it('should migrate HttpClientTestingModule', async () => { - writeFile( - '/index.ts', - ` - import { TestBed } from '@angular/core/testing'; - import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; - import { AppComponent } from './app.component'; - - TestBed.configureTestingModule({ - declarations: [AppComponent], - imports: [HttpClientTestingModule], - }); - `, - ); - - await runMigration(); - - const content = tree.readContent('/index.ts'); - expect(content).toContain(`'@angular/common/http/testing'`); - expect(content).toContain(`'@angular/common/http'`); - expect(content).toMatch(/import.*provideHttpClient.*withInterceptorsFromDi.*from/); - expect(content).not.toContain(`HttpClientTestingModule`); - expect(content).toMatch(/import.*provideHttpClientTesting/); - expect(content).toContain( - `provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()`, - ); - expect(content).toContain(`declarations: [AppComponent]`); - }); - - it('should not migrate HttpClientTestingModule from outside package', async () => { - writeFile( - '/index.ts', - ` - import { TestBed } from '@angular/core/testing'; - import { HttpClientTestingModule, HttpTestingController } from '@not-angular/common/http/testing'; - - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - `, - ); - - await runMigration(); - - const content = tree.readContent('/index.ts'); - expect(content).toContain(`@not-angular/common/http/testing`); - expect(content).toContain(`HttpClientTestingModule`); - expect(content).not.toContain('provideHttpClientTesting'); - }); - - it('should migrate NgModule + TestBed.configureTestingModule in the same file', async () => { - writeFile( - '/index.ts', - ` - import { NgModule } from '@angular/core'; - import { TestBed } from '@angular/core/testing'; - import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; - import { HttpClientModule, HttpClientJsonpModule } from '@angular/common/http'; - - @NgModule({ - template: '', - imports: [HttpClientModule,HttpClientJsonpModule], - }) - export class MyModule {} - - - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - `, - ); - - await runMigration(); - - const content = tree.readContent('/index.ts'); - expect(content).toContain(`@angular/common/http`); - expect(content).toContain(`@angular/common/http/testing`); - expect(content).not.toContain(`HttpClientModule`); - expect(content).not.toContain(`HttpClientTestingModule`); - expect(content).toContain('provideHttpClientTesting'); - expect(content).toContain('provideHttpClient(withInterceptorsFromDi(), withJsonpSupport())'); - expect(content).toContain( - 'provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()', - ); - - expect(content).toContain( - `import { provideHttpClient, withInterceptorsFromDi, withJsonpSupport } from '@angular/common/http';`, - ); - expect(content).toContain( - `import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';`, - ); - }); - - it('should migrate HttpClientTestingModule in NgModule', async () => { - writeFile( - '/index.ts', - ` - import { NgModule } from '@angular/core'; - import { TestBed } from '@angular/core/testing'; - import { HttpClientTestingModule } from '@angular/common/http/testing'; - - @NgModule({ - declarations: [AppComponent], - imports: [HttpClientTestingModule], - }) - export class TestModule {} - `, - ); - - await runMigration(); - - const content = tree.readContent('/index.ts'); - expect(content).toMatch(/import.*provideHttpClient.*withInterceptorsFromDi.*from/); - expect(content).not.toContain(`HttpClientTestingModule`); - expect(content).toMatch(/import.*provideHttpClientTesting/); - expect(content).toContain( - `provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()`, - ); - expect(content).toContain(`declarations: [AppComponent]`); - }); - - it('should not change a decorator with no arguments', async () => { - writeFile( - '/index.ts', - ` - import { NgModule } from '@angular/core'; - import { HttpClientModule, HttpClientJsonpModule } from '@angular/common/http'; - - @NgModule() - export class MyModule {} - `, - ); - - await runMigration(); - - const content = tree.readContent('/index.ts'); - expect(content).not.toContain('HttpClientModule'); - expect(content).not.toContain('provideHttpClient'); - }); -}); diff --git a/packages/core/schematics/test/invalid_two_way_bindings_spec.ts b/packages/core/schematics/test/invalid_two_way_bindings_spec.ts deleted file mode 100644 index 02bd22429bbbf..0000000000000 --- a/packages/core/schematics/test/invalid_two_way_bindings_spec.ts +++ /dev/null @@ -1,396 +0,0 @@ -/** - * @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.io/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'; -import {runfiles} from '@bazel/runfiles'; -import shx from 'shelljs'; - -describe('Invalid two-way bindings migration', () => { - let runner: SchematicTestRunner; - let host: TempScopedNodeJsSyncHost; - let tree: UnitTestTree; - let tmpDirPath: string; - let previousWorkingDir: string; - - function writeFile(filePath: string, contents: string) { - host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents)); - } - - function runMigration() { - return runner.runSchematic('invalid-two-way-bindings', {}, tree); - } - - beforeEach(() => { - runner = new SchematicTestRunner('test', runfiles.resolvePackageRelative('../migrations.json')); - host = new TempScopedNodeJsSyncHost(); - tree = new UnitTestTree(new HostTree(host)); - - writeFile('/tsconfig.json', '{}'); - writeFile( - '/angular.json', - JSON.stringify({ - version: 1, - projects: {t: {root: '', architect: {build: {options: {tsConfig: './tsconfig.json'}}}}}, - }), - ); - - previousWorkingDir = shx.pwd(); - tmpDirPath = getSystemPath(host.root); - - // Switch into the temporary directory path. This allows us to run - // the schematic against our custom unit test tree. - shx.cd(tmpDirPath); - }); - - afterEach(() => { - shx.cd(previousWorkingDir); - shx.rm('-r', tmpDirPath); - }); - - it('should migrate a two-way binding with a binary expression', async () => { - writeFile( - '/comp.ts', - ` - import {Component} from '@angular/core'; - - @Component({ - template: \`\` - }) - class Comp {} - `, - ); - - await runMigration(); - const content = tree.readContent('/comp.ts'); - expect(content).toContain( - 'template: ``', - ); - }); - - it('should migrate a two-way binding with a single unary expression', async () => { - writeFile( - '/comp.ts', - ` - import {Component} from '@angular/core'; - - @Component({ - template: \`\` - }) - class Comp {} - `, - ); - - await runMigration(); - const content = tree.readContent('/comp.ts'); - expect(content).toContain( - 'template: ``', - ); - }); - - it('should migrate a two-way binding with a nested unary expression', async () => { - writeFile( - '/comp.ts', - ` - import {Component} from '@angular/core'; - - @Component({ - template: \`\` - }) - class Comp {} - `, - ); - - await runMigration(); - const content = tree.readContent('/comp.ts'); - expect(content).toContain( - 'template: ``', - ); - }); - - it('should migrate a two-way binding with a conditional expression', async () => { - writeFile( - '/comp.ts', - ` - import {Component} from '@angular/core'; - - @Component({ - template: \`\` - }) - class Comp {} - `, - ); - - await runMigration(); - const content = tree.readContent('/comp.ts'); - expect(content).toContain( - 'template: ``', - ); - }); - - it('should migrate multiple inline templates in the same file', async () => { - writeFile( - '/comp.ts', - ` - import {Component} from '@angular/core'; - - @Component({ - template: \`\` - }) - class Comp {} - - @Component({ - template: \`\` - }) - class Comp2 {} - `, - ); - - await runMigration(); - const content = tree.readContent('/comp.ts'); - expect(content).toContain( - 'template: ``', - ); - expect(content).toContain( - 'template: ``', - ); - }); - - it('should migrate an external template', async () => { - writeFile( - '/comp.ts', - ` - import {Component} from '@angular/core'; - - @Component({ - templateUrl: './comp.html' - }) - class Comp {} - `, - ); - - writeFile( - '/comp.html', - [`
`, `hello`, ``, ``, ``, `
`].join('\n'), - ); - - await runMigration(); - const content = tree.readContent('/comp.html'); - - expect(content).toBe( - [ - `
`, - `hello`, - ``, - ``, - ``, - `
`, - ].join('\n'), - ); - }); - - it('should migrate a template referenced by multiple components', async () => { - writeFile( - '/comp-a.ts', - ` - import {Component} from '@angular/core'; - - @Component({ - templateUrl: './comp.html' - }) - class CompA {} - `, - ); - - writeFile( - '/comp-b.ts', - ` - import {Component} from '@angular/core'; - - @Component({ - templateUrl: './comp.html' - }) - class CompB {} - `, - ); - - writeFile( - '/comp.html', - [`
`, `hello`, ``, ``, ``, `
`].join('\n'), - ); - - await runMigration(); - const content = tree.readContent('/comp.html'); - - expect(content).toBe( - [ - `
`, - `hello`, - ``, - ``, - ``, - `
`, - ].join('\n'), - ); - }); - - it('should migrate multiple two-way bindings on the same element', async () => { - writeFile( - '/comp.ts', - ` - import {Component} from '@angular/core'; - - @Component({ - template: \`\` - }) - class Comp {} - `, - ); - - await runMigration(); - const content = tree.readContent('/comp.ts'); - expect(content).toContain( - 'template: ``', - ); - }); - - it('should not stop the migration if a file cannot be read', async () => { - writeFile( - '/comp.ts', - ` - import {Component} from '@angular/core'; - - @Component({ - templateUrl: './does-not-exist.html' - }) - class BrokenComp {} - `, - ); - - writeFile( - '/other-comp.ts', - ` - import {Component} from '@angular/core'; - - @Component({ - templateUrl: './comp.html' - }) - class Comp {} - `, - ); - - writeFile('/comp.html', ''); - - await runMigration(); - const content = tree.readContent('/comp.html'); - - expect(content).toBe(''); - }); - - it('should migrate a component that is not at the top level', async () => { - writeFile( - '/comp.ts', - ` - import {Component} from '@angular/core'; - - function foo() { - @Component({ - template: \`\` - }) - class Comp {} - } - `, - ); - - await runMigration(); - const content = tree.readContent('/comp.ts'); - - expect(content).toContain( - 'template: ``', - ); - }); - - it('should preserve a valid expression', async () => { - writeFile( - '/comp.ts', - ` - import {Component} from '@angular/core'; - - @Component({ - template: \`\` - }) - class Comp {} - `, - ); - - await runMigration(); - const content = tree.readContent('/comp.ts'); - expect(content).toContain('template: ``'); - }); - - it('should not migrate an invalid expression if an event listener for the same binding exists', async () => { - writeFile( - '/comp.ts', - ` - import {Component} from '@angular/core'; - - @Component({ - template: \`\` - }) - class Comp {} - `, - ); - - await runMigration(); - const content = tree.readContent('/comp.ts'); - expect(content).toContain( - 'template: ``', - ); - }); - - it('should not migrate an invalid expression if a property binding for the same binding exists', async () => { - writeFile( - '/comp.ts', - ` - import {Component} from '@angular/core'; - - @Component({ - template: \`\` - }) - class Comp {} - `, - ); - - await runMigration(); - const content = tree.readContent('/comp.ts'); - expect(content).toContain('template: ``'); - }); - - it('should migrate a two-way binding on an ng-template', async () => { - writeFile( - '/comp.ts', - ` - import {Component} from '@angular/core'; - - @Component({ - template: \`\` - }) - class Comp {} - `, - ); - - await runMigration(); - const content = tree.readContent('/comp.ts'); - expect(content).toContain( - 'template: ``', - ); - }); -}); From 9e83ca8bc6948d453dc8f2e51847cad7a582a57d Mon Sep 17 00:00:00 2001 From: Ilia Brahinets Date: Tue, 27 Aug 2024 15:55:43 +0300 Subject: [PATCH 13/41] docs: Fix 'EnvironmentInjector' description (#57582) PR Close #57582 --- adev/src/content/guide/di/hierarchical-dependency-injection.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adev/src/content/guide/di/hierarchical-dependency-injection.md b/adev/src/content/guide/di/hierarchical-dependency-injection.md index 75465edfb4187..ac464ebe1f539 100644 --- a/adev/src/content/guide/di/hierarchical-dependency-injection.md +++ b/adev/src/content/guide/di/hierarchical-dependency-injection.md @@ -16,7 +16,7 @@ Angular has two injector hierarchies: | Injector hierarchies | Details | |:--- |:--- | -| `EnvironmentInjector` hierarchy | Configure an `ElementInjector` in this hierarchy using `@Injectable()` or `providers` array in `ApplicationConfig`. | +| `EnvironmentInjector` hierarchy | Configure an `EnvironmentInjector` in this hierarchy using `@Injectable()` or `providers` array in `ApplicationConfig`. | | `ElementInjector` hierarchy | Created implicitly at each DOM element. An `ElementInjector` is empty by default unless you configure it in the `providers` property on `@Directive()` or `@Component()`. | From 36d8d19dc1563e8f30e361786640a3b563bdaa4c Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Wed, 28 Aug 2024 12:23:53 -0700 Subject: [PATCH 14/41] refactor(core): removing a pending task delays stability until the next tick (#57570) This commit updates the public API for pending tasks to schedule an application tick, effectively making the stability async when the last task is removed. PR Close #57570 --- .../scheduling/zoneless_scheduling.ts | 3 +++ .../scheduling/zoneless_scheduling_impl.ts | 17 +++++++++++++++++ packages/core/src/pending_tasks.ts | 11 ++++++++++- .../core/test/acceptance/pending_tasks_spec.ts | 7 +++++-- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/core/src/change_detection/scheduling/zoneless_scheduling.ts b/packages/core/src/change_detection/scheduling/zoneless_scheduling.ts index b6c92eb4d4da3..c4330a2933483 100644 --- a/packages/core/src/change_detection/scheduling/zoneless_scheduling.ts +++ b/packages/core/src/change_detection/scheduling/zoneless_scheduling.ts @@ -47,6 +47,9 @@ export const enum NotificationSource { ViewDetachedFromDOM, // Applying animations might result in new DOM state and should rerun render hooks AsyncAnimationsLoaded, + // The scheduler is notified when a pending task is removed via the public API. + // This allows us to make stability async, delayed until the next application tick. + PendingTaskRemoved, } /** diff --git a/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts b/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts index 19a3a2d83ab2a..8ed9ef6a158cb 100644 --- a/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts +++ b/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts @@ -153,6 +153,15 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler { force = true; break; } + case NotificationSource.PendingTaskRemoved: { + // Removing a pending task via the public API forces a scheduled tick, ensuring that + // stability is async and delayed until there was at least an opportunity to run + // application synchronization. This prevents some footguns when working with the + // public API for pending tasks where developers attempt to update application state + // immediately after removing the last task. + force = true; + break; + } case NotificationSource.ViewDetachedFromDOM: case NotificationSource.ViewAttached: case NotificationSource.RenderHook: @@ -229,6 +238,14 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler { return; } + // If we reach the tick and there is no work to be done in ApplicationRef.tick, + // skip it altogether and clean up. There may be no work if, for example, the only + // event that notified the scheduler was the removal of a pending task. + if (this.appRef.dirtyFlags === ApplicationRefDirtyFlags.None) { + this.cleanup(); + return; + } + // The scheduler used to pass "whether to check views" as a boolean flag instead of setting // fine-grained dirtiness flags, and global checking was always used on the first pass. This // created an interesting edge case: if a notification made a view dirty and then ticked via the diff --git a/packages/core/src/pending_tasks.ts b/packages/core/src/pending_tasks.ts index fcab157771418..59b885e7f91b8 100644 --- a/packages/core/src/pending_tasks.ts +++ b/packages/core/src/pending_tasks.ts @@ -11,6 +11,10 @@ import {BehaviorSubject} from 'rxjs'; import {inject} from './di/injector_compatibility'; import {ɵɵdefineInjectable} from './di/interface/defs'; import {OnDestroy} from './interface/lifecycle_hooks'; +import { + ChangeDetectionScheduler, + NotificationSource, +} from './change_detection/scheduling/zoneless_scheduling'; /** * Internal implementation of the pending tasks service. @@ -82,13 +86,18 @@ export class PendingTasks implements OnDestroy { */ export class ExperimentalPendingTasks { private internalPendingTasks = inject(PendingTasks); + private scheduler = inject(ChangeDetectionScheduler); /** * Adds a new task that should block application's stability. * @returns A cleanup function that removes a task when called. */ add(): () => void { const taskId = this.internalPendingTasks.add(); - return () => this.internalPendingTasks.remove(taskId); + return () => { + // Notifying the scheduler will hold application stability open until the next tick. + this.scheduler.notify(NotificationSource.PendingTaskRemoved); + this.internalPendingTasks.remove(taskId); + }; } /** @nocollapse */ diff --git a/packages/core/test/acceptance/pending_tasks_spec.ts b/packages/core/test/acceptance/pending_tasks_spec.ts index 28750cc55cb77..a3fa0dea45806 100644 --- a/packages/core/test/acceptance/pending_tasks_spec.ts +++ b/packages/core/test/acceptance/pending_tasks_spec.ts @@ -72,9 +72,12 @@ describe('public ExperimentalPendingTasks', () => { const appRef = TestBed.inject(ApplicationRef); const pendingTasks = TestBed.inject(ExperimentalPendingTasks); - const taskA = pendingTasks.add(); + const removeTaskA = pendingTasks.add(); + await expectAsync(applicationRefIsStable(appRef)).toBeResolvedTo(false); + removeTaskA(); + // stability is delayed until a tick happens await expectAsync(applicationRefIsStable(appRef)).toBeResolvedTo(false); - taskA(); + TestBed.inject(ApplicationRef).tick(); await expectAsync(applicationRefIsStable(appRef)).toBeResolvedTo(true); }); }); From 3896f86865ad78e261fae2846a2c84abe7bfdd4d Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Fri, 30 Aug 2024 10:55:08 +0000 Subject: [PATCH 15/41] refactor(migrations): switch from esbuild to Rollup for schematics bundling (#57602) Replaces esbuild with Rollup for bundling schematics to support code splitting, as esbuild does not handle code splitting when targeting CommonJS modules. **Before:** ``` du -sh dist/bin/packages/core/npm_package/schematics 7.7M dist/bin/packages/core/npm_package/schematics ``` **After:** ``` du -sh dist/bin/packages/core/npm_package/schematics 3.1M dist/bin/packages/core/npm_package/schematics ``` PR Close #57602 --- packages/core/schematics/BUILD.bazel | 33 +++++++++-- packages/core/schematics/collection.json | 21 +++---- .../control-flow-migration/BUILD.bazel | 14 +---- .../ng-generate/control-flow-migration/ifs.ts | 1 - .../control-flow-migration/index.ts | 2 +- .../control-flow-migration/migration.ts | 2 - .../ng-generate/inject-migration/BUILD.bazel | 14 +---- .../ng-generate/inject-migration/index.ts | 2 +- .../route-lazy-loading/BUILD.bazel | 14 +---- .../ng-generate/route-lazy-loading/index.ts | 2 +- .../standalone-migration/BUILD.bazel | 14 +---- .../ng-generate/standalone-migration/index.ts | 2 +- packages/core/schematics/rollup.config.js | 59 +++++++++++++++++++ packages/core/schematics/test/BUILD.bazel | 12 +--- tools/defaults.bzl | 19 ------ 15 files changed, 105 insertions(+), 106 deletions(-) create mode 100644 packages/core/schematics/rollup.config.js diff --git a/packages/core/schematics/BUILD.bazel b/packages/core/schematics/BUILD.bazel index 12006deb4d733..206ce03ae3aee 100644 --- a/packages/core/schematics/BUILD.bazel +++ b/packages/core/schematics/BUILD.bazel @@ -1,4 +1,5 @@ load("//tools:defaults.bzl", "pkg_npm") +load("@npm//@bazel/rollup:index.bzl", "rollup_bundle") exports_files([ "tsconfig.json", @@ -20,9 +21,33 @@ pkg_npm( validate = False, visibility = ["//packages/core:__pkg__"], deps = [ - "//packages/core/schematics/ng-generate/control-flow-migration:bundle", - "//packages/core/schematics/ng-generate/inject-migration:bundle", - "//packages/core/schematics/ng-generate/route-lazy-loading:bundle", - "//packages/core/schematics/ng-generate/standalone-migration:bundle", + ":bundles", + ], +) + +rollup_bundle( + name = "bundles", + config_file = ":rollup.config.js", + entry_points = { + "//packages/core/schematics/ng-generate/control-flow-migration:index.ts": "control-flow-migration", + "//packages/core/schematics/ng-generate/inject-migration:index.ts": "inject-migration", + "//packages/core/schematics/ng-generate/route-lazy-loading:index.ts": "route-lazy-loading", + "//packages/core/schematics/ng-generate/standalone-migration:index.ts": "standalone-migration", + }, + format = "cjs", + link_workspace_root = True, + output_dir = True, + sourcemap = "false", + visibility = [ + "//packages/core/schematics/test:__pkg__", + ], + deps = [ + "//packages/core/schematics/ng-generate/control-flow-migration", + "//packages/core/schematics/ng-generate/inject-migration", + "//packages/core/schematics/ng-generate/route-lazy-loading", + "//packages/core/schematics/ng-generate/standalone-migration", + "@npm//@rollup/plugin-commonjs", + "@npm//@rollup/plugin-node-resolve", + "@npm//magic-string", ], ) diff --git a/packages/core/schematics/collection.json b/packages/core/schematics/collection.json index 319b3b4bd9bf8..f32e1bfc3f860 100644 --- a/packages/core/schematics/collection.json +++ b/packages/core/schematics/collection.json @@ -2,34 +2,27 @@ "schematics": { "standalone-migration": { "description": "Converts the entire application or a part of it to standalone", - "factory": "./ng-generate/standalone-migration/bundle", + "factory": "./bundles/standalone-migration#migrate", "schema": "./ng-generate/standalone-migration/schema.json", - "aliases": [ - "standalone" - ] + "aliases": ["standalone"] }, "control-flow-migration": { "description": "Converts the entire application to block control flow syntax", - "factory": "./ng-generate/control-flow-migration/bundle", + "factory": "./bundles/control-flow-migration#migrate", "schema": "./ng-generate/control-flow-migration/schema.json", - "aliases": [ - "control-flow" - ] + "aliases": ["control-flow"] }, "inject-migration": { "description": "Converts usages of constructor-based injection to the inject() function", - "factory": "./ng-generate/inject-migration/bundle", + "factory": "./bundles/inject-migration#migrate", "schema": "./ng-generate/inject-migration/schema.json", - "aliases": [ - "inject" - ] + "aliases": ["inject"] }, "route-lazy-loading-migration": { "description": "Updates route definitions to use lazy-loading of components instead of eagerly referencing them", - "factory": "./ng-generate/route-lazy-loading/bundle", + "factory": "./bundles/route-lazy-loading#migrate", "schema": "./ng-generate/route-lazy-loading/schema.json", "aliases": ["route-lazy-loading"] } } } - diff --git a/packages/core/schematics/ng-generate/control-flow-migration/BUILD.bazel b/packages/core/schematics/ng-generate/control-flow-migration/BUILD.bazel index c4d36f92d5f22..1ceb022b139ad 100644 --- a/packages/core/schematics/ng-generate/control-flow-migration/BUILD.bazel +++ b/packages/core/schematics/ng-generate/control-flow-migration/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "esbuild_no_sourcemaps", "ts_library") +load("//tools:defaults.bzl", "ts_library") package( default_visibility = [ @@ -25,15 +25,3 @@ ts_library( "@npm//typescript", ], ) - -esbuild_no_sourcemaps( - name = "bundle", - entry_point = ":index.ts", - external = [ - "@angular-devkit/*", - "typescript", - ], - format = "cjs", - platform = "node", - deps = [":control-flow-migration"], -) diff --git a/packages/core/schematics/ng-generate/control-flow-migration/ifs.ts b/packages/core/schematics/ng-generate/control-flow-migration/ifs.ts index a8937f32dbff8..f89b96745a465 100644 --- a/packages/core/schematics/ng-generate/control-flow-migration/ifs.ts +++ b/packages/core/schematics/ng-generate/control-flow-migration/ifs.ts @@ -23,7 +23,6 @@ import { getPlaceholder, hasLineBreaks, parseTemplate, - PlaceholderKind, reduceNestingOffset, } from './util'; diff --git a/packages/core/schematics/ng-generate/control-flow-migration/index.ts b/packages/core/schematics/ng-generate/control-flow-migration/index.ts index 6d1c98f7087b8..480acb968a402 100644 --- a/packages/core/schematics/ng-generate/control-flow-migration/index.ts +++ b/packages/core/schematics/ng-generate/control-flow-migration/index.ts @@ -21,7 +21,7 @@ interface Options { format: boolean; } -export default function (options: Options): Rule { +export function migrate(options: Options): Rule { return async (tree: Tree, context: SchematicContext) => { const basePath = process.cwd(); const pathToMigrate = normalizePath(join(basePath, options.path)); diff --git a/packages/core/schematics/ng-generate/control-flow-migration/migration.ts b/packages/core/schematics/ng-generate/control-flow-migration/migration.ts index 908c572a63841..d7802dbcc50a3 100644 --- a/packages/core/schematics/ng-generate/control-flow-migration/migration.ts +++ b/packages/core/schematics/ng-generate/control-flow-migration/migration.ts @@ -23,10 +23,8 @@ import { import { canRemoveCommonModule, formatTemplate, - parseTemplate, processNgTemplates, removeImports, - validateI18nStructure, validateMigratedTemplate, } from './util'; diff --git a/packages/core/schematics/ng-generate/inject-migration/BUILD.bazel b/packages/core/schematics/ng-generate/inject-migration/BUILD.bazel index 97d202828ea5c..7917fe4c3dda5 100644 --- a/packages/core/schematics/ng-generate/inject-migration/BUILD.bazel +++ b/packages/core/schematics/ng-generate/inject-migration/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "esbuild_no_sourcemaps", "ts_library") +load("//tools:defaults.bzl", "ts_library") package( default_visibility = [ @@ -24,15 +24,3 @@ ts_library( "@npm//typescript", ], ) - -esbuild_no_sourcemaps( - name = "bundle", - entry_point = ":index.ts", - external = [ - "@angular-devkit/*", - "typescript", - ], - format = "cjs", - platform = "node", - deps = [":inject-migration"], -) diff --git a/packages/core/schematics/ng-generate/inject-migration/index.ts b/packages/core/schematics/ng-generate/inject-migration/index.ts index dd8d45716e80f..16ccc9e72ccf5 100644 --- a/packages/core/schematics/ng-generate/inject-migration/index.ts +++ b/packages/core/schematics/ng-generate/inject-migration/index.ts @@ -18,7 +18,7 @@ interface Options extends MigrationOptions { path: string; } -export default function (options: Options): Rule { +export function migrate(options: Options): Rule { return async (tree: Tree) => { const basePath = process.cwd(); const pathToMigrate = normalizePath(join(basePath, options.path)); diff --git a/packages/core/schematics/ng-generate/route-lazy-loading/BUILD.bazel b/packages/core/schematics/ng-generate/route-lazy-loading/BUILD.bazel index a72d228d7ceb2..8cf537123711d 100644 --- a/packages/core/schematics/ng-generate/route-lazy-loading/BUILD.bazel +++ b/packages/core/schematics/ng-generate/route-lazy-loading/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "esbuild_no_sourcemaps", "ts_library") +load("//tools:defaults.bzl", "ts_library") package( default_visibility = [ @@ -26,15 +26,3 @@ ts_library( "@npm//typescript", ], ) - -esbuild_no_sourcemaps( - name = "bundle", - entry_point = ":index.ts", - external = [ - "@angular-devkit/*", - "typescript", - ], - format = "cjs", - platform = "node", - deps = [":route-lazy-loading"], -) diff --git a/packages/core/schematics/ng-generate/route-lazy-loading/index.ts b/packages/core/schematics/ng-generate/route-lazy-loading/index.ts index f8f494d5a4ff8..bfa4e3ac8c6a5 100644 --- a/packages/core/schematics/ng-generate/route-lazy-loading/index.ts +++ b/packages/core/schematics/ng-generate/route-lazy-loading/index.ts @@ -20,7 +20,7 @@ interface Options { path: string; } -export default function (options: Options): Rule { +export function migrate(options: Options): Rule { return async (tree, context) => { const {buildPaths} = await getProjectTsConfigPaths(tree); const basePath = process.cwd(); diff --git a/packages/core/schematics/ng-generate/standalone-migration/BUILD.bazel b/packages/core/schematics/ng-generate/standalone-migration/BUILD.bazel index d72d3e6870b1a..5e68b8c969e95 100644 --- a/packages/core/schematics/ng-generate/standalone-migration/BUILD.bazel +++ b/packages/core/schematics/ng-generate/standalone-migration/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "esbuild_no_sourcemaps", "ts_library") +load("//tools:defaults.bzl", "ts_library") package( default_visibility = [ @@ -26,15 +26,3 @@ ts_library( "@npm//typescript", ], ) - -esbuild_no_sourcemaps( - name = "bundle", - entry_point = ":index.ts", - external = [ - "@angular-devkit/*", - "typescript", - ], - format = "cjs", - platform = "node", - deps = [":standalone-migration"], -) diff --git a/packages/core/schematics/ng-generate/standalone-migration/index.ts b/packages/core/schematics/ng-generate/standalone-migration/index.ts index 2e16704904ab9..1fe7bffbc6fb0 100644 --- a/packages/core/schematics/ng-generate/standalone-migration/index.ts +++ b/packages/core/schematics/ng-generate/standalone-migration/index.ts @@ -32,7 +32,7 @@ interface Options { mode: MigrationMode; } -export default function (options: Options): Rule { +export function migrate(options: Options): Rule { return async (tree, context) => { const {buildPaths, testPaths} = await getProjectTsConfigPaths(tree); const basePath = process.cwd(); diff --git a/packages/core/schematics/rollup.config.js b/packages/core/schematics/rollup.config.js new file mode 100644 index 0000000000000..354d123d5066e --- /dev/null +++ b/packages/core/schematics/rollup.config.js @@ -0,0 +1,59 @@ +/** + * @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.io/license + */ +const {nodeResolve} = require('@rollup/plugin-node-resolve'); +const commonjs = require('@rollup/plugin-commonjs'); +const MagicString = require('magic-string'); + +/** Removed license banners from input files. */ +const stripBannerPlugin = { + name: 'strip-license-banner', + transform(code, _filePath) { + const banner = /(\/\**\s+\*\s@license.*?\*\/)/s.exec(code); + if (!banner) { + return; + } + + const [bannerContent] = banner; + const magicString = new MagicString(code); + const pos = code.indexOf(bannerContent); + magicString.remove(pos, pos + bannerContent.length).trimStart(); + + return { + code: magicString.toString(), + map: magicString.generateMap({ + hires: true, + }), + }; + }, +}; + +const banner = `'use strict'; +/** + * @license Angular v0.0.0-PLACEHOLDER + * (c) 2010-2024 Google LLC. https://angular.io/ + * License: MIT + */`; + +const plugins = [ + nodeResolve({ + jail: process.cwd(), + }), + stripBannerPlugin, + commonjs(), +]; + +const config = { + plugins, + external: ['typescript', 'tslib', /@angular-devkit\/.+/], + output: { + exports: 'auto', + banner, + }, +}; + +module.exports = config; diff --git a/packages/core/schematics/test/BUILD.bazel b/packages/core/schematics/test/BUILD.bazel index 004222dfe88c7..814b23de0022f 100644 --- a/packages/core/schematics/test/BUILD.bazel +++ b/packages/core/schematics/test/BUILD.bazel @@ -3,7 +3,7 @@ load("//tools:defaults.bzl", "jasmine_node_test", "ts_library") ts_library( name = "test_lib", testonly = True, - srcs = glob(["**/*.ts"]), + srcs = glob(["*.ts"]), deps = [ "//packages/core/schematics/utils", "@npm//@angular-devkit/core", @@ -17,22 +17,14 @@ ts_library( jasmine_node_test( name = "test", data = [ + "//packages/core/schematics:bundles", "//packages/core/schematics:collection.json", "//packages/core/schematics:migrations.json", - "//packages/core/schematics/ng-generate/control-flow-migration", - "//packages/core/schematics/ng-generate/control-flow-migration:bundle", "//packages/core/schematics/ng-generate/control-flow-migration:static_files", - "//packages/core/schematics/ng-generate/inject-migration", - "//packages/core/schematics/ng-generate/inject-migration:bundle", "//packages/core/schematics/ng-generate/inject-migration:static_files", - "//packages/core/schematics/ng-generate/route-lazy-loading", - "//packages/core/schematics/ng-generate/route-lazy-loading:bundle", "//packages/core/schematics/ng-generate/route-lazy-loading:static_files", - "//packages/core/schematics/ng-generate/standalone-migration", - "//packages/core/schematics/ng-generate/standalone-migration:bundle", "//packages/core/schematics/ng-generate/standalone-migration:static_files", ], - templated_args = ["--nobazel_run_linker"], deps = [ ":test_lib", "@npm//shelljs", diff --git a/tools/defaults.bzl b/tools/defaults.bzl index 9a2488e95fee1..2b522246ef42d 100644 --- a/tools/defaults.bzl +++ b/tools/defaults.bzl @@ -10,7 +10,6 @@ load("@npm//@bazel/protractor:index.bzl", _protractor_web_test_suite = "protract load("@npm//typescript:index.bzl", "tsc") load("@npm//@angular/build-tooling/bazel/app-bundling:index.bzl", _app_bundle = "app_bundle") load("@npm//@angular/build-tooling/bazel/http-server:index.bzl", _http_server = "http_server") -load("@npm//@angular/build-tooling/bazel:filter_outputs.bzl", "filter_outputs") load("@npm//@angular/build-tooling/bazel/karma:index.bzl", _karma_web_test = "karma_web_test", _karma_web_test_suite = "karma_web_test_suite") load("@npm//@angular/build-tooling/bazel/api-golden:index.bzl", _api_golden_test = "api_golden_test", _api_golden_test_npm_package = "api_golden_test_npm_package") load("@npm//@angular/build-tooling/bazel:extract_js_module_output.bzl", "extract_js_module_output") @@ -651,24 +650,6 @@ def esbuild(args = None, **kwargs): **kwargs ) -def esbuild_no_sourcemaps(name, **kwargs): - esbuild_target_name = "%s.with-sourcemap" % name - - # Unlike linked, when using external the .js output file does not contain a //# sourceMappingURL= comment. - # See: https://esbuild.github.io/api/#sourcemap - esbuild( - name = esbuild_target_name, - sourcemap = "external", - output = "%s.js" % name, - **kwargs - ) - - filter_outputs( - name = name, - target = esbuild_target_name, - filters = ["%s.js" % name], - ) - def esbuild_checked_in(name, **kwargs): esbuild_esm_bundle( name = "%s_generated" % name, From fe5c4e086add655bf53315d71b0736ff758c7199 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Mon, 26 Aug 2024 12:54:52 -0700 Subject: [PATCH 16/41] fix(elements): support `output()`-shaped outputs (#57535) Previously Elements was assuming that every output was an RxJS `Subject` and supports `.pipe()`. This is not true for `output()`-based outputs which have `.subscribe()` but not `.pipe()`. This commit fixes such outputs by using a `new Observable` instead of `map` to forward outputs. PR Close #57535 --- .../elements/src/component-factory-strategy.ts | 10 +++++++--- .../test/component-factory-strategy_spec.ts | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/elements/src/component-factory-strategy.ts b/packages/elements/src/component-factory-strategy.ts index cc86f51314bcd..2f571daa5ac1e 100644 --- a/packages/elements/src/component-factory-strategy.ts +++ b/packages/elements/src/component-factory-strategy.ts @@ -18,9 +18,10 @@ import { ɵChangeDetectionScheduler as ChangeDetectionScheduler, ɵNotificationSource as NotificationSource, ɵViewRef as ViewRef, + OutputRef, } from '@angular/core'; import {merge, Observable, ReplaySubject} from 'rxjs'; -import {map, switchMap} from 'rxjs/operators'; +import {switchMap} from 'rxjs/operators'; import { NgElementStrategy, @@ -219,8 +220,11 @@ export class ComponentNgElementStrategy implements NgElementStrategy { protected initializeOutputs(componentRef: ComponentRef): void { const eventEmitters: Observable[] = this.componentFactory.outputs.map( ({propName, templateName}) => { - const emitter: EventEmitter = componentRef.instance[propName]; - return emitter.pipe(map((value) => ({name: templateName, value}))); + const emitter: EventEmitter | OutputRef = componentRef.instance[propName]; + return new Observable((observer) => { + const sub = emitter.subscribe((value) => observer.next({name: templateName, value})); + return () => sub.unsubscribe(); + }); }, ); diff --git a/packages/elements/test/component-factory-strategy_spec.ts b/packages/elements/test/component-factory-strategy_spec.ts index 4e53f7e3e3881..5fb8f7aef70e9 100644 --- a/packages/elements/test/component-factory-strategy_spec.ts +++ b/packages/elements/test/component-factory-strategy_spec.ts @@ -16,6 +16,7 @@ import { Input, NgZone, Output, + OutputEmitterRef, SimpleChange, SimpleChanges, createComponent, @@ -113,6 +114,18 @@ describe('ComponentFactoryNgElementStrategy', () => { ]); }); + it('should listen to output() emitters', () => { + const events: NgElementStrategyEvent[] = []; + strategy.events.subscribe((e) => events.push(e)); + + componentRef.instance.output3.emit('output-a'); + componentRef.instance.output3.emit('output-b'); + expect(events).toEqual([ + {name: 'templateOutput3', value: 'output-a'}, + {name: 'templateOutput3', value: 'output-b'}, + ]); + }); + it('should initialize the component with initial values', () => { expect(strategy.getInputValue('fooFoo')).toBe('fooFoo-1'); expect(componentRef.instance.fooFoo).toBe('fooFoo-1'); @@ -369,6 +382,7 @@ export class CdTrackerDir { export class TestComponent { @Output('templateOutput1') output1 = new Subject(); @Output('templateOutput2') output2 = new Subject(); + @Output('templateOutput3') output3 = new OutputEmitterRef(); @Input() fooFoo: unknown; @Input({alias: 'my-bar-bar'}) barBar: unknown; From c2892fee58d28ffec0dfeaad6a5d6822c040cf03 Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Mon, 26 Aug 2024 21:53:37 +0200 Subject: [PATCH 17/41] fix(http): Dynamicaly call the global fetch implementation (#57531) Instead of using the reference that existing when `FetchBackend` is setup. fixes #57527 PR Close #57531 --- packages/common/http/src/fetch.ts | 6 ++-- packages/common/http/test/fetch_spec.ts | 40 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/common/http/src/fetch.ts b/packages/common/http/src/fetch.ts index 876df4b0c8573..65b43e9e57379 100644 --- a/packages/common/http/src/fetch.ts +++ b/packages/common/http/src/fetch.ts @@ -52,9 +52,11 @@ function getResponseUrl(response: Response): string | null { */ @Injectable() export class FetchBackend implements HttpBackend { - // We need to bind the native fetch to its context or it will throw an "illegal invocation" + // We use an arrow function to always reference the current global implementation of `fetch`. + // This is helpful for cases when the global `fetch` implementation is modified by external code, + // see https://github.com/angular/angular/issues/57527. private readonly fetchImpl = - inject(FetchFactory, {optional: true})?.fetch ?? fetch.bind(globalThis); + inject(FetchFactory, {optional: true})?.fetch ?? ((...args) => globalThis.fetch(...args)); private readonly ngZone = inject(NgZone); handle(request: HttpRequest): Observable> { diff --git a/packages/common/http/test/fetch_spec.ts b/packages/common/http/test/fetch_spec.ts index cb20ee7c7bf6c..628640a502557 100644 --- a/packages/common/http/test/fetch_spec.ts +++ b/packages/common/http/test/fetch_spec.ts @@ -12,11 +12,14 @@ import {Observable, of, Subject} from 'rxjs'; import {catchError, retry, scan, skip, take, toArray} from 'rxjs/operators'; import { + HttpClient, HttpDownloadProgressEvent, HttpErrorResponse, HttpHeaderResponse, HttpParams, HttpStatusCode, + provideHttpClient, + withFetch, } from '../public_api'; import {FetchBackend, FetchFactory} from '../src/fetch'; @@ -416,6 +419,43 @@ describe('FetchBackend', async () => { fetchMock.mockFlush(0, 'CORS 0 status'); }); }); + + describe('dynamic global fetch', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideHttpClient(withFetch())], + }); + }); + + it('should use the current implementation of the global fetch', async () => { + const originalFetch = globalThis.fetch; + + try { + const fakeFetch = jasmine + .createSpy('', () => Promise.resolve(new Response(JSON.stringify({foo: 'bar'})))) + .and.callThrough(); + globalThis.fetch = fakeFetch; + + const client = TestBed.inject(HttpClient); + expect(fakeFetch).not.toHaveBeenCalled(); + let response = await client.get('').toPromise(); + expect(fakeFetch).toHaveBeenCalled(); + expect(response).toEqual({foo: 'bar'}); + + // We dynamicaly change the implementation of fetch + const fakeFetch2 = jasmine + .createSpy('', () => Promise.resolve(new Response(JSON.stringify({foo: 'baz'})))) + .and.callThrough(); + globalThis.fetch = fakeFetch2; + response = await client.get('').toPromise(); + expect(response).toEqual({foo: 'baz'}); + } finally { + // We need to restore the original fetch implementation, else the tests might become flaky + globalThis.fetch = originalFetch; + } + }); + }); }); export class MockFetchFactory extends FetchFactory { From be2e49639bda831831ad62d49253db942a83fd46 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Mon, 12 Aug 2024 14:34:07 -0700 Subject: [PATCH 18/41] feat(core): introduce `afterRenderEffect` (#57549) Implement the `afterRenderEffect` primitive, which creates effect(s) that run as part of Angular's `afterRender` sequence. `afterRenderEffect` is a useful primitive for expressing DOM operations in a declarative, reactive way. The API itself mirrors `afterRender` and `afterNextRender` with one big difference: values are propagated from phase to phase as signals instead of as plain values. As a result, later phases may not need to execute if the values returned by earlier phases do not change. PR Close #57549 --- goldens/public-api/core/index.api.md | 11 + .../size-tracking/integration-payloads.json | 4 +- .../src/core_reactivity_export_internal.ts | 1 + .../core/src/render3/after_render/hooks.ts | 2 +- .../render3/reactivity/after_render_effect.ts | 381 ++++++++++++++++++ .../acceptance/after_render_effect_spec.ts | 336 +++++++++++++++ .../bundling/defer/bundle.golden_symbols.json | 9 + 7 files changed, 741 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/render3/reactivity/after_render_effect.ts create mode 100644 packages/core/test/acceptance/after_render_effect_spec.ts diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index a53793cf74cd7..79fac41c58767 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -49,6 +49,17 @@ export function afterRender(spec: { // @public export function afterRender(callback: VoidFunction, options?: AfterRenderOptions): AfterRenderRef; +// @public +export function afterRenderEffect(callback: (onCleanup: EffectCleanupRegisterFn) => void, options?: Omit): AfterRenderRef; + +// @public +export function afterRenderEffect(spec: { + earlyRead?: (onCleanup: EffectCleanupRegisterFn) => E; + write?: (...args: [...ɵFirstAvailableSignal<[E]>, EffectCleanupRegisterFn]) => W; + mixedReadWrite?: (...args: [...ɵFirstAvailableSignal<[W, E]>, EffectCleanupRegisterFn]) => M; + read?: (...args: [...ɵFirstAvailableSignal<[M, W, E]>, EffectCleanupRegisterFn]) => void; +}, options?: Omit): AfterRenderRef; + // @public export interface AfterRenderOptions { injector?: Injector; diff --git a/goldens/size-tracking/integration-payloads.json b/goldens/size-tracking/integration-payloads.json index 70dc3630fa32d..9f06cf3ff8d9c 100644 --- a/goldens/size-tracking/integration-payloads.json +++ b/goldens/size-tracking/integration-payloads.json @@ -7,8 +7,8 @@ }, "cli-hello-world-ivy-i18n": { "uncompressed": { - "main": 130590, - "polyfills": 34676 + "main": 135813, + "polyfills": 35883 } }, "cli-hello-world-lazy": { diff --git a/packages/core/src/core_reactivity_export_internal.ts b/packages/core/src/core_reactivity_export_internal.ts index 4cc3fa43ce647..be39859102f3f 100644 --- a/packages/core/src/core_reactivity_export_internal.ts +++ b/packages/core/src/core_reactivity_export_internal.ts @@ -23,4 +23,5 @@ export { EffectCleanupRegisterFn, EffectScheduler as ɵEffectScheduler, } from './render3/reactivity/effect'; +export {afterRenderEffect, ɵFirstAvailableSignal} from './render3/reactivity/after_render_effect'; export {assertNotInReactiveContext} from './render3/reactivity/asserts'; diff --git a/packages/core/src/render3/after_render/hooks.ts b/packages/core/src/render3/after_render/hooks.ts index 50c87a539d630..c9670f76a03c2 100644 --- a/packages/core/src/render3/after_render/hooks.ts +++ b/packages/core/src/render3/after_render/hooks.ts @@ -459,6 +459,6 @@ function afterRenderImpl( } /** `AfterRenderRef` that does nothing. */ -const NOOP_AFTER_RENDER_REF: AfterRenderRef = { +export const NOOP_AFTER_RENDER_REF: AfterRenderRef = { destroy() {}, }; diff --git a/packages/core/src/render3/reactivity/after_render_effect.ts b/packages/core/src/render3/reactivity/after_render_effect.ts new file mode 100644 index 0000000000000..42f8a25900067 --- /dev/null +++ b/packages/core/src/render3/reactivity/after_render_effect.ts @@ -0,0 +1,381 @@ +/** + * @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.io/license + */ + +import { + consumerAfterComputation, + consumerBeforeComputation, + consumerPollProducersForChange, + producerAccessed, + SIGNAL, + SIGNAL_NODE, + type SignalNode, +} from '@angular/core/primitives/signals'; + +import {type Signal} from '../reactivity/api'; +import {type EffectCleanupFn, type EffectCleanupRegisterFn} from './effect'; + +import { + ChangeDetectionScheduler, + NotificationSource, +} from '../../change_detection/scheduling/zoneless_scheduling'; +import {Injector} from '../../di/injector'; +import {inject} from '../../di/injector_compatibility'; +import {AfterRenderImpl, AfterRenderManager, AfterRenderSequence} from '../after_render/manager'; +import {AfterRenderPhase, type AfterRenderRef} from '../after_render/api'; +import {NOOP_AFTER_RENDER_REF, type AfterRenderOptions} from '../after_render/hooks'; +import {DestroyRef} from '../../linker/destroy_ref'; +import {assertNotInReactiveContext} from './asserts'; +import {assertInInjectionContext} from '../../di/contextual'; +import {isPlatformBrowser} from '../util/misc_utils'; + +const NOT_SET = Symbol('NOT_SET'); +const EMPTY_CLEANUP_SET = new Set<() => void>(); + +/** Callback type for an `afterRenderEffect` phase effect */ +type AfterRenderPhaseEffectHook = ( + // Either a cleanup function or a pipelined value and a cleanup function + ...args: + | [onCleanup: EffectCleanupRegisterFn] + | [previousPhaseValue: unknown, onCleanup: EffectCleanupRegisterFn] +) => unknown; + +/** + * Reactive node in the graph for this `afterRenderEffect` phase effect. + * + * This node type extends `SignalNode` because `afterRenderEffect` phases effects produce a value + * which is consumed as a `Signal` by subsequent phases. + */ +interface AfterRenderPhaseEffectNode extends SignalNode { + /** The phase of the effect implemented by this node */ + phase: AfterRenderPhase; + /** The sequence of phases to which this node belongs, used for state of the whole sequence */ + sequence: AfterRenderEffectSequence; + /** The user's callback function */ + userFn: AfterRenderPhaseEffectHook; + /** Signal function that retrieves the value of this node, used as the value for the next phase */ + signal: Signal; + /** Registered cleanup functions, or `null` if none have ever been registered */ + cleanup: Set<() => void> | null; + /** Pre-bound helper function passed to the user's callback which writes to `this.cleanup` */ + registerCleanupFn: EffectCleanupRegisterFn; + /** Entrypoint to running this effect that's given to the `afterRender` machinery */ + phaseFn(previousValue?: unknown): unknown; +} + +const AFTER_RENDER_PHASE_EFFECT_NODE = { + ...SIGNAL_NODE, + consumerIsAlwaysLive: true, + consumerAllowSignalWrites: true, + value: NOT_SET, + cleanup: null, + /** Called when the effect becomes dirty */ + consumerMarkedDirty(this: AfterRenderPhaseEffectNode): void { + if (this.sequence.impl.executing) { + // If hooks are in the middle of executing, then it matters whether this node has yet been + // executed within its sequence. If not, then we don't want to notify the scheduler since + // this node will be reached naturally. + if (this.sequence.lastPhase === null || this.sequence.lastPhase < this.phase) { + return; + } + + // If during the execution of a later phase an earlier phase became dirty, then we should not + // run any further phases until the earlier one reruns. + this.sequence.erroredOrDestroyed = true; + } + + // Either hooks are not running, or we're marking a node dirty that has already run within its + // sequence. + this.sequence.scheduler.notify(NotificationSource.RenderHook); + }, + phaseFn(this: AfterRenderPhaseEffectNode, previousValue?: unknown): unknown { + this.sequence.lastPhase = this.phase; + + if (!this.dirty) { + return this.signal; + } + + this.dirty = false; + if (this.value !== NOT_SET && !consumerPollProducersForChange(this)) { + // None of our producers report a change since the last time they were read, so no + // recomputation of our value is necessary. + return this.signal; + } + + // Run any needed cleanup functions. + try { + for (const cleanupFn of this.cleanup ?? EMPTY_CLEANUP_SET) { + cleanupFn(); + } + } finally { + // Even if a cleanup function errors, ensure it's cleared. + this.cleanup?.clear(); + } + + // Prepare to call the user's effect callback. If there was a previous phase, then it gave us + // its value as a `Signal`, otherwise `previousValue` will be `undefined`. + const args: unknown[] = []; + if (previousValue !== undefined) { + args.push(previousValue); + } + args.push(this.registerCleanupFn); + + // Call the user's callback in our reactive context. + const prevConsumer = consumerBeforeComputation(this); + let newValue; + try { + newValue = this.userFn.apply(null, args as any); + } finally { + consumerAfterComputation(this, prevConsumer); + } + + if (this.value === NOT_SET || !this.equal(this.value, newValue)) { + this.value = newValue; + this.version++; + } + + return this.signal; + }, +}; + +/** + * An `AfterRenderSequence` that manages an `afterRenderEffect`'s phase effects. + */ +class AfterRenderEffectSequence extends AfterRenderSequence { + /** + * While this sequence is executing, this tracks the last phase which was called by the + * `afterRender` machinery. + * + * When a phase effect is marked dirty, this is used to determine whether it's already run or not. + */ + lastPhase: AfterRenderPhase | null = null; + + /** + * The reactive nodes for each phase, if a phase effect is defined for that phase. + * + * These are initialized to `undefined` but set in the constructor. + */ + private readonly nodes: [ + AfterRenderPhaseEffectNode | undefined, + AfterRenderPhaseEffectNode | undefined, + AfterRenderPhaseEffectNode | undefined, + AfterRenderPhaseEffectNode | undefined, + ] = [undefined, undefined, undefined, undefined]; + + constructor( + impl: AfterRenderImpl, + effectHooks: Array, + readonly scheduler: ChangeDetectionScheduler, + destroyRef: DestroyRef, + ) { + // Note that we also initialize the underlying `AfterRenderSequence` hooks to `undefined` and + // populate them as we create reactive nodes below. + super(impl, [undefined, undefined, undefined, undefined], false, destroyRef); + + // Setup a reactive node for each phase. + for (const phase of AfterRenderImpl.PHASES) { + const effectHook = effectHooks[phase]; + if (effectHook === undefined) { + continue; + } + + const node = Object.create(AFTER_RENDER_PHASE_EFFECT_NODE) as AfterRenderPhaseEffectNode; + node.sequence = this; + node.phase = phase; + node.userFn = effectHook; + node.dirty = true; + node.signal = (() => { + producerAccessed(node); + return node.value; + }) as Signal; + node.signal[SIGNAL] = node; + node.registerCleanupFn = (fn: EffectCleanupFn) => + (node.cleanup ??= new Set<() => void>()).add(fn); + + this.nodes[phase] = node; + + // Install the upstream hook which runs the `phaseFn` for this phase. + this.hooks[phase] = (value) => node.phaseFn(value); + } + } + + override afterRun(): void { + super.afterRun(); + // We're done running this sequence, so reset `lastPhase`. + this.lastPhase = null; + } + + override destroy(): void { + super.destroy(); + + // Run the cleanup functions for each node. + for (const node of this.nodes) { + for (const fn of node?.cleanup ?? EMPTY_CLEANUP_SET) { + fn(); + } + } + } +} + +/** + * An argument list containing the first non-never type in the given type array, or an empty + * argument list if there are no non-never types in the type array. + */ +export type ɵFirstAvailableSignal = T extends [infer H, ...infer R] + ? [H] extends [never] + ? ɵFirstAvailableSignal + : [Signal] + : []; + +/** + * Register an effect that, when triggered, is invoked when the application finishes rendering, during the + * `mixedReadWrite` phase. + * + *
+ * + * You should prefer specifying an explicit phase for the effect instead, or you risk significant + * performance degradation. + * + *
+ * + * Note that callback-based `afterRenderEffect`s will run + * - in the order it they are registered + * - only when dirty + * - on browser platforms only + * - during the `mixedReadWrite` phase + * + *
+ * + * Components are not guaranteed to be [hydrated](guide/hydration) before the callback runs. + * You must use caution when directly reading or writing the DOM and layout. + * + *
+ * + * @param callback An effect callback function to register + * @param options Options to control the behavior of the callback + * + * @experimental + */ +export function afterRenderEffect( + callback: (onCleanup: EffectCleanupRegisterFn) => void, + options?: Omit, +): AfterRenderRef; +/** + * Register effects that, when triggered, are invoked when the application finishes rendering, + * during the specified phases. The available phases are: + * - `earlyRead` + * Use this phase to **read** from the DOM before a subsequent `write` callback, for example to + * perform custom layout that the browser doesn't natively support. Prefer the `read` phase if + * reading can wait until after the write phase. **Never** write to the DOM in this phase. + * - `write` + * Use this phase to **write** to the DOM. **Never** read from the DOM in this phase. + * - `mixedReadWrite` + * Use this phase to read from and write to the DOM simultaneously. **Never** use this phase if + * it is possible to divide the work among the other phases instead. + * - `read` + * Use this phase to **read** from the DOM. **Never** write to the DOM in this phase. + * + *
+ * + * You should prefer using the `read` and `write` phases over the `earlyRead` and `mixedReadWrite` + * phases when possible, to avoid performance degradation. + * + *
+ * + * Note that: + * - Effects run in the following phase order, only when dirty through signal dependencies: + * 1. `earlyRead` + * 2. `write` + * 3. `mixedReadWrite` + * 4. `read` + * - `afterRenderEffect`s in the same phase run in the order they are registered. + * - `afterRenderEffect`s run on browser platforms only, they will not run on the server. + * - `afterRenderEffect`s will run at least once. + * + * The first phase callback to run as part of this spec will receive no parameters. Each + * subsequent phase callback in this spec will receive the return value of the previously run + * phase callback as a `Signal`. This can be used to coordinate work across multiple phases. + * + * Angular is unable to verify or enforce that phases are used correctly, and instead + * relies on each developer to follow the guidelines documented for each value and + * carefully choose the appropriate one, refactoring their code if necessary. By doing + * so, Angular is better able to minimize the performance degradation associated with + * manual DOM access, ensuring the best experience for the end users of your application + * or library. + * + *
+ * + * Components are not guaranteed to be [hydrated](guide/hydration) before the callback runs. + * You must use caution when directly reading or writing the DOM and layout. + * + *
+ * + * @param spec The effect functions to register + * @param options Options to control the behavior of the effects + * + * @usageNotes + * + * Use `afterRenderEffect` to create effects that will read or write from the DOM and thus should + * run after rendering. + * + * @experimental + */ +export function afterRenderEffect( + spec: { + earlyRead?: (onCleanup: EffectCleanupRegisterFn) => E; + write?: (...args: [...ɵFirstAvailableSignal<[E]>, EffectCleanupRegisterFn]) => W; + mixedReadWrite?: (...args: [...ɵFirstAvailableSignal<[W, E]>, EffectCleanupRegisterFn]) => M; + read?: (...args: [...ɵFirstAvailableSignal<[M, W, E]>, EffectCleanupRegisterFn]) => void; + }, + options?: Omit, +): AfterRenderRef; + +export function afterRenderEffect( + callbackOrSpec: + | ((onCleanup: EffectCleanupRegisterFn) => void) + | { + earlyRead?: (onCleanup: EffectCleanupRegisterFn) => E; + write?: (...args: [...ɵFirstAvailableSignal<[E]>, EffectCleanupRegisterFn]) => W; + mixedReadWrite?: ( + ...args: [...ɵFirstAvailableSignal<[W, E]>, EffectCleanupRegisterFn] + ) => M; + read?: (...args: [...ɵFirstAvailableSignal<[M, W, E]>, EffectCleanupRegisterFn]) => void; + }, + options?: Omit, +): AfterRenderRef { + ngDevMode && + assertNotInReactiveContext( + afterRenderEffect, + 'Call `afterRenderEffect` outside of a reactive context. For example, create the render ' + + 'effect inside the component constructor`.', + ); + + !options?.injector && assertInInjectionContext(afterRenderEffect); + const injector = options?.injector ?? inject(Injector); + + if (!isPlatformBrowser(injector)) { + return NOOP_AFTER_RENDER_REF; + } + + const scheduler = injector.get(ChangeDetectionScheduler); + const manager = injector.get(AfterRenderManager); + manager.impl ??= injector.get(AfterRenderImpl); + + let spec = callbackOrSpec; + if (typeof spec === 'function') { + spec = {mixedReadWrite: callbackOrSpec as any}; + } + + const sequence = new AfterRenderEffectSequence( + manager.impl, + [spec.earlyRead, spec.write, spec.mixedReadWrite, spec.read] as AfterRenderPhaseEffectHook[], + scheduler, + injector.get(DestroyRef), + ); + manager.impl.register(sequence); + return sequence; +} diff --git a/packages/core/test/acceptance/after_render_effect_spec.ts b/packages/core/test/acceptance/after_render_effect_spec.ts new file mode 100644 index 0000000000000..3a0fd119f2433 --- /dev/null +++ b/packages/core/test/acceptance/after_render_effect_spec.ts @@ -0,0 +1,336 @@ +/** + * @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.io/license + */ + +import { + afterNextRender, + ApplicationRef, + computed, + PLATFORM_ID, + provideExperimentalZonelessChangeDetection, + signal, +} from '@angular/core'; +import {afterRenderEffect} from '@angular/core/src/render3/reactivity/after_render_effect'; +import {TestBed} from '@angular/core/testing'; + +describe('afterRenderEffect', () => { + beforeEach(() => { + TestBed.configureTestingModule({providers: [{provide: PLATFORM_ID, useValue: 'browser'}]}); + }); + + it('should support a single callback in the mixedReadWrite phase', () => { + const log: string[] = []; + const appRef = TestBed.inject(ApplicationRef); + afterNextRender(() => log.push('before'), {injector: appRef.injector}); + afterRenderEffect(() => log.push('mixedReadWrite'), {injector: appRef.injector}); + afterNextRender(() => log.push('after'), {injector: appRef.injector}); + appRef.tick(); + expect(log).toEqual(['before', 'mixedReadWrite', 'after']); + }); + + it('should run once', () => { + const log: string[] = []; + const appRef = TestBed.inject(ApplicationRef); + afterRenderEffect( + { + earlyRead: () => log.push('earlyRead'), + write: () => log.push('write'), + mixedReadWrite: () => log.push('mixedReadWrite'), + read: () => log.push('read'), + }, + {injector: appRef.injector}, + ); + appRef.tick(); + expect(log).toEqual(['earlyRead', 'write', 'mixedReadWrite', 'read']); + }); + + it('should not run when not dirty', () => { + const log: string[] = []; + const appRef = TestBed.inject(ApplicationRef); + + afterRenderEffect( + { + earlyRead: () => log.push('earlyRead'), + write: () => log.push('write'), + mixedReadWrite: () => log.push('mixedReadWrite'), + read: () => log.push('read'), + }, + {injector: appRef.injector}, + ); + + // We expect an initial run, and clear the log. + appRef.tick(); + log.length = 0; + + // The second tick() should not re-run the effects as they're not dirty. + appRef.tick(); + expect(log.length).toBe(0); + }); + + it('should run when made dirty via signal', () => { + const log: string[] = []; + const appRef = TestBed.inject(ApplicationRef); + const counter = signal(0); + + afterRenderEffect( + { + // `earlyRead` depends on `counter` + earlyRead: () => log.push(`earlyRead: ${counter()}`), + // `write` does not + write: () => log.push('write'), + }, + {injector: appRef.injector}, + ); + appRef.tick(); + log.length = 0; + + counter.set(1); + appRef.tick(); + + expect(log).toEqual(['earlyRead: 1']); + }); + + it('should not run when not actually dirty from signals', () => { + const log: string[] = []; + const appRef = TestBed.inject(ApplicationRef); + const counter = signal(0); + const isEven = computed(() => counter() % 2 === 0); + + afterRenderEffect( + { + earlyRead: () => log.push(`earlyRead: ${isEven()}`), + }, + {injector: appRef.injector}, + ); + appRef.tick(); + log.length = 0; + + counter.set(2); + appRef.tick(); + + // Should not have run since `isEven()` didn't actually change despite becoming dirty. + expect(log.length).toBe(0); + }); + + it('should pass data from one phase to the next via signal', () => { + const log: string[] = []; + const appRef = TestBed.inject(ApplicationRef); + const counter = signal(0); + + afterRenderEffect( + { + // `earlyRead` calculates `isEven` + earlyRead: () => counter() % 2 === 0, + write: (isEven) => log.push(`isEven: ${isEven()}`), + }, + {injector: appRef.injector}, + ); + appRef.tick(); + log.length = 0; + + // isEven: false + counter.set(1); + appRef.tick(); + + // isEven: true + counter.set(2); + appRef.tick(); + + // No change (no log). + counter.set(4); + appRef.tick(); + + expect(log).toEqual(['isEven: false', 'isEven: true']); + }); + + it('should run cleanup functions before re-running phase effects', () => { + const log: string[] = []; + const appRef = TestBed.inject(ApplicationRef); + const counter = signal(0); + + afterRenderEffect( + { + earlyRead: (onCleanup) => { + onCleanup(() => log.push('cleanup earlyRead')); + log.push(`earlyRead: ${counter()}`); + // Calculate isEven: + return counter() % 2 === 0; + }, + write: (isEven, onCleanup) => { + onCleanup(() => log.push('cleanup write')); + log.push(`write: ${isEven()}`); + }, + }, + {injector: appRef.injector}, + ); + + // Initial run should run both effects with no cleanup + appRef.tick(); + expect(log).toEqual(['earlyRead: 0', 'write: true']); + log.length = 0; + + // A counter of 1 will clean up and rerun both effects. + counter.set(1); + appRef.tick(); + expect(log).toEqual(['cleanup earlyRead', 'earlyRead: 1', 'cleanup write', 'write: false']); + log.length = 0; + + // A counter of 3 will clean up and rerun the earlyRead phase only. + counter.set(3); + appRef.tick(); + expect(log).toEqual(['cleanup earlyRead', 'earlyRead: 3']); + log.length = 0; + + // A counter of 4 will then clean up and rerun both effects. + counter.set(4); + appRef.tick(); + expect(log).toEqual(['cleanup earlyRead', 'earlyRead: 4', 'cleanup write', 'write: true']); + }); + + it('should run cleanup functions when destroyed', () => { + const log: string[] = []; + const appRef = TestBed.inject(ApplicationRef); + + const ref = afterRenderEffect( + { + earlyRead: (onCleanup) => { + onCleanup(() => log.push('cleanup earlyRead')); + }, + write: (_, onCleanup) => { + onCleanup(() => log.push('cleanup write')); + }, + mixedReadWrite: (_, onCleanup) => { + onCleanup(() => log.push('cleanup mixedReadWrite')); + }, + read: (_, onCleanup) => { + onCleanup(() => log.push('cleanup read')); + }, + }, + {injector: appRef.injector}, + ); + + appRef.tick(); + expect(log.length).toBe(0); + + ref.destroy(); + expect(log).toEqual([ + 'cleanup earlyRead', + 'cleanup write', + 'cleanup mixedReadWrite', + 'cleanup read', + ]); + }); + + it('should schedule CD when dirty', async () => { + TestBed.configureTestingModule({ + providers: [ + provideExperimentalZonelessChangeDetection(), + {provide: PLATFORM_ID, useValue: 'browser'}, + ], + }); + + const log: string[] = []; + const appRef = TestBed.inject(ApplicationRef); + const counter = signal(0); + + afterRenderEffect( + {earlyRead: () => log.push(`earlyRead: ${counter()}`)}, + {injector: appRef.injector}, + ); + await appRef.whenStable(); + expect(log).toEqual(['earlyRead: 0']); + + counter.set(1); + await appRef.whenStable(); + expect(log).toEqual(['earlyRead: 0', 'earlyRead: 1']); + }); + + it('should cause a re-run for hooks that re-dirty themselves', () => { + const log: string[] = []; + const appRef = TestBed.inject(ApplicationRef); + const counter = signal(0); + + afterRenderEffect( + { + earlyRead: () => { + log.push(`counter: ${counter()}`); + + // Cause a re-execution when counter is 1. + if (counter() === 1) { + counter.set(0); + } + }, + }, + {injector: appRef.injector}, + ); + + appRef.tick(); + log.length = 0; + + counter.set(1); + appRef.tick(); + expect(log).toEqual(['counter: 1', 'counter: 0']); + }); + + it('should cause a re-run for hooks that re-dirty earlier hooks', () => { + const log: string[] = []; + const appRef = TestBed.inject(ApplicationRef); + const counter = signal(0); + + afterRenderEffect( + { + earlyRead: () => { + log.push(`earlyRead: ${counter()}`); + return counter(); + }, + write: (value) => { + log.push(`write: ${value()}`); + // Cause a re-execution when value from earlyRead is 1. + if (value() === 1) { + counter.set(0); + } + }, + }, + {injector: appRef.injector}, + ); + + appRef.tick(); + log.length = 0; + + counter.set(1); + appRef.tick(); + expect(log).toEqual(['earlyRead: 1', 'write: 1', 'earlyRead: 0', 'write: 0']); + }); + + it('should not run later hooks when an earlier hook is re-dirtied', () => { + const log: string[] = []; + const appRef = TestBed.inject(ApplicationRef); + const counter = signal(0); + + afterRenderEffect( + { + earlyRead: () => { + const value = counter(); + log.push(`earlyRead: ${value}`); + if (value === 1) { + counter.set(0); + } + return value; + }, + write: (value) => log.push(`write: ${value()}`), + }, + {injector: appRef.injector}, + ); + + appRef.tick(); + log.length = 0; + + counter.set(1); + appRef.tick(); + expect(log).toEqual(['earlyRead: 1', 'earlyRead: 0', 'write: 0']); + }); +}); diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 24cd75b8b3dbb..42344e4a04a8b 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -698,6 +698,9 @@ { "name": "configureViewWithDirective" }, + { + "name": "consumerAfterComputation" + }, { "name": "consumerBeforeComputation" }, @@ -761,6 +764,9 @@ { "name": "deepForEachProvider" }, + { + "name": "defaultEquals" + }, { "name": "defaultErrorHandler" }, @@ -1064,6 +1070,9 @@ { "name": "init_advance" }, + { + "name": "init_after_render_effect" + }, { "name": "init_all" }, From 1f87cba204705f6b3bf6fb61a4b5c4e88993c551 Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Thu, 29 Aug 2024 12:01:33 -0700 Subject: [PATCH 19/41] release: bump Angular DevTools version to 1.0.18 (#57585) PR Close #57585 --- .../projects/shell-browser/src/manifest/manifest.chrome.json | 4 ++-- .../projects/shell-browser/src/manifest/manifest.firefox.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/devtools/projects/shell-browser/src/manifest/manifest.chrome.json b/devtools/projects/shell-browser/src/manifest/manifest.chrome.json index 3df092d67c4a7..45f1e763cdf9e 100644 --- a/devtools/projects/shell-browser/src/manifest/manifest.chrome.json +++ b/devtools/projects/shell-browser/src/manifest/manifest.chrome.json @@ -3,8 +3,8 @@ "short_name": "Angular DevTools", "name": "Angular DevTools", "description": "Angular DevTools extends Chrome DevTools adding Angular specific debugging and profiling capabilities.", - "version": "1.0.17", - "version_name": "1.0.17", + "version": "1.0.18", + "version_name": "1.0.18", "minimum_chrome_version": "102", "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" diff --git a/devtools/projects/shell-browser/src/manifest/manifest.firefox.json b/devtools/projects/shell-browser/src/manifest/manifest.firefox.json index 6a258628581e6..ccfb16dc8fd59 100644 --- a/devtools/projects/shell-browser/src/manifest/manifest.firefox.json +++ b/devtools/projects/shell-browser/src/manifest/manifest.firefox.json @@ -3,7 +3,7 @@ "short_name": "Angular DevTools", "name": "Angular DevTools", "description": "Angular DevTools extends Firefox DevTools adding Angular specific debugging and profiling capabilities.", - "version": "1.0.17", + "version": "1.0.18", "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", "icons": { "16": "assets/icon16.png", From 108a88ca61c811b9f389c4f749f93214e0b09f42 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Tue, 3 Sep 2024 16:36:03 +0000 Subject: [PATCH 20/41] refactor(language-service): support caching in code refactorings (#57645) Instead of creating instances of refactoring whenever the language service loads, we should lazily create these upon first "application". This will speed up loading of the language service, while it also gives us the ability to implement caching in code refactorings to speed up subsequent applications; leveraging e.g. the `script versions` from the TS server project. PR Close #57645 --- .../language-service/src/language_service.ts | 10 ++++++++-- .../refactorings/convert_to_signal_input.ts | 20 ++++++------------- .../src/refactorings/refactoring.ts | 20 ++++++++++++++++--- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/packages/language-service/src/language_service.ts b/packages/language-service/src/language_service.ts index 269790b86d3b5..647d2de240d26 100644 --- a/packages/language-service/src/language_service.ts +++ b/packages/language-service/src/language_service.ts @@ -47,7 +47,7 @@ import { getPropertyAssignmentFromValue, } from './utils/ts_utils'; import {getTemplateInfoAtPosition, isTypeScriptFile} from './utils'; -import {allRefactorings} from './refactorings/refactoring'; +import {ActiveRefactoring, allRefactorings} from './refactorings/refactoring'; type LanguageServiceConfig = Omit; @@ -55,6 +55,7 @@ export class LanguageService { private options: CompilerOptions; readonly compilerFactory: CompilerFactory; private readonly codeFixes: CodeFixes; + private readonly activeRefactorings = new Map(); constructor( private readonly project: ts.server.Project, @@ -575,7 +576,12 @@ export class LanguageService { } return this.withCompilerAndPerfTracing(PerfPhase.LSApplyRefactoring, (compiler) => { - return matchingRefactoring.computeEditsForFix( + if (!this.activeRefactorings.has(refactorName)) { + this.activeRefactorings.set(refactorName, new matchingRefactoring(this.project)); + } + const activeRefactoring = this.activeRefactorings.get(refactorName)!; + + return activeRefactoring.computeEditsForFix( compiler, this.options, fileName, diff --git a/packages/language-service/src/refactorings/convert_to_signal_input.ts b/packages/language-service/src/refactorings/convert_to_signal_input.ts index 3483439043978..4f9360f453c25 100644 --- a/packages/language-service/src/refactorings/convert_to_signal_input.ts +++ b/packages/language-service/src/refactorings/convert_to_signal_input.ts @@ -15,20 +15,12 @@ import { } from '@angular/core/schematics/migrations/signal-migration/src/input_detection/incompatibility'; import {ApplyRefactoringProgressFn} from '@angular/language-service/api'; import ts from 'typescript'; -import { - InputNode, - isInputContainerNode, -} from '../../../core/schematics/migrations/signal-migration/src/input_detection/input_node'; -import {KnownInputInfo} from '../../../core/schematics/migrations/signal-migration/src/input_detection/known_inputs'; +import {isInputContainerNode} from '../../../core/schematics/migrations/signal-migration/src/input_detection/input_node'; import {SignalInputMigration} from '../../../core/schematics/migrations/signal-migration/src/migration'; -import { - getInputDescriptor, - isInputDescriptor, -} from '../../../core/schematics/migrations/signal-migration/src/utils/input_id'; import {groupReplacementsByFile} from '../../../core/schematics/utils/tsurge/helpers/group_replacements'; import {findTightestNode, getParentClassDeclaration} from '../utils/ts_utils'; +import type {ActiveRefactoring} from './refactoring'; import {isTypeScriptFile} from '../utils'; -import type {Refactoring} from './refactoring'; /** * Language service refactoring action that can convert `@Input()` @@ -38,11 +30,11 @@ import type {Refactoring} from './refactoring'; * extension and ask for the input to be migrated. All references, imports and * the declaration are updated automatically. */ -export class ConvertToSignalInputRefactoring implements Refactoring { - id = 'convert-to-signal-input'; - description = '(experimental fixer): Convert @Input() to a signal input'; +export class ConvertToSignalInputRefactoring implements ActiveRefactoring { + static id = 'convert-to-signal-input'; + static description = '(experimental fixer): Convert @Input() to a signal input'; - isApplicable( + static isApplicable( compiler: NgCompiler, fileName: string, positionOrRange: number | ts.TextRange, diff --git a/packages/language-service/src/refactorings/refactoring.ts b/packages/language-service/src/refactorings/refactoring.ts index 0344d8174c442..ed0b60cbefa67 100644 --- a/packages/language-service/src/refactorings/refactoring.ts +++ b/packages/language-service/src/refactorings/refactoring.ts @@ -13,16 +13,21 @@ import {CompilerOptions} from '@angular/compiler-cli'; import {ConvertToSignalInputRefactoring} from './convert_to_signal_input'; /** - * Interface that describes a refactoring. + * Interface exposing static metadata for a {@link Refactoring}, + * exposed via static fields. * * A refactoring may be applicable at a given position inside * a file. If it becomes applicable, the language service will suggest * it as a code action. * * Later, the user can request edits for the refactoring lazily, upon - * e.g. click. + * e.g. click. The refactoring class is then instantiated and will be + * re-used for future applications, allowing for efficient re-use of e.g + * analysis data. */ export interface Refactoring { + new (project: ts.server.Project): ActiveRefactoring; + /** Unique id of the refactoring. */ id: string; @@ -35,7 +40,16 @@ export interface Refactoring { fileName: string, positionOrRange: number | ts.TextRange, ): boolean; +} +/** + * Interface that describes an active refactoring instance. A + * refactoring may be lazily instantiated whenever the refactoring + * is requested to be applied. + * + * More information can be found in {@link Refactoring} + */ +export interface ActiveRefactoring { /** Computes the edits for the refactoring. */ computeEditsForFix( compiler: NgCompiler, @@ -46,4 +60,4 @@ export interface Refactoring { ): Promise; } -export const allRefactorings: Refactoring[] = [new ConvertToSignalInputRefactoring()]; +export const allRefactorings: Refactoring[] = [ConvertToSignalInputRefactoring]; From 1251ee0ac6140d58684b70a461c8f1b42a4f6cab Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Tue, 3 Sep 2024 16:37:22 +0000 Subject: [PATCH 21/41] refactor(migrations): share logic for looking up property access (#57645) This commit shares the logic for looking up a property access, using `ts.Type` information. This is helpful in case where no linked TS symbols are available; e.g. templates in test files without TCB. This helper will be useful for handling object expansion in the signal input migration; resolving references like `const {x} = this`. PR Close #57645 --- .../template_reference_visitor.ts | 25 ++++----- .../helpers/ast/lookup_property_access.ts | 51 +++++++++++++++++++ 2 files changed, 60 insertions(+), 16 deletions(-) create mode 100644 packages/core/schematics/utils/tsurge/helpers/ast/lookup_property_access.ts diff --git a/packages/core/schematics/migrations/signal-migration/src/input_detection/template_reference_visitor.ts b/packages/core/schematics/migrations/signal-migration/src/input_detection/template_reference_visitor.ts index 5e367dbf217be..ad397a22a6fe9 100644 --- a/packages/core/schematics/migrations/signal-migration/src/input_detection/template_reference_visitor.ts +++ b/packages/core/schematics/migrations/signal-migration/src/input_detection/template_reference_visitor.ts @@ -39,6 +39,7 @@ import {MigrationHost} from '../migration_host'; import {InputDescriptor, InputUniqueKey} from '../utils/input_id'; import {InputIncompatibilityReason} from './incompatibility'; import {BoundAttribute, BoundEvent} from '../../../../../../compiler/src/render3/r3_ast'; +import {lookupPropertyAccess} from '../../../../utils/tsurge/helpers/ast/lookup_property_access'; /** * Interface describing a reference to an input from within @@ -420,22 +421,14 @@ function traverseReceiverAndLookupSymbol( return null; } - let type = checker.getTypeAtLocation(componentClass.name); - let symbol: ts.Symbol | null = null; - - for (const propName of path) { - // Note: Always assume `NonNullable` for the path, when using the non-TCB lookups. This - // is necessary to support e.g. ternary narrowing in host bindings. The assumption is that - // an input is only accessed if its receivers are all non-nullable anyway. - const propSymbol = type.getNonNullableType().getProperty(propName); - if (propSymbol === undefined) { - return null; - } - symbol = propSymbol; - type = checker.getTypeOfSymbol(propSymbol); - } - - return symbol; + const classType = checker.getTypeAtLocation(componentClass.name); + return ( + lookupPropertyAccess(checker, classType, path, { + // Necessary to avoid breaking the resolution if there is + // some narrowing involved. E.g. `myClass ? myClass.input`. + ignoreNullability: true, + })?.symbol ?? null + ); } /** Whether the given node refers to a two-way binding AST node. */ diff --git a/packages/core/schematics/utils/tsurge/helpers/ast/lookup_property_access.ts b/packages/core/schematics/utils/tsurge/helpers/ast/lookup_property_access.ts new file mode 100644 index 0000000000000..5e9e1c6a0c57c --- /dev/null +++ b/packages/core/schematics/utils/tsurge/helpers/ast/lookup_property_access.ts @@ -0,0 +1,51 @@ +/** + * @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.io/license + */ + +import ts from 'typescript'; + +/** + * Attempts to look up the given property access chain using + * the type checker. + * + * Notably this is not as safe as using the type checker directly to + * retrieve symbols of a given identifier, but in some cases this is + * a necessary approach to compensate e.g. for a lack of TCB information + * when processing Angular templates. + * + * The path is a list of properties to be accessed sequentially on the + * given type. + */ +export function lookupPropertyAccess( + checker: ts.TypeChecker, + type: ts.Type, + path: string[], + options: {ignoreNullability?: boolean} = {}, +): {symbol: ts.Symbol; type: ts.Type} | null { + let symbol: ts.Symbol | null = null; + + for (const propName of path) { + // Note: We support assuming `NonNullable` for the pathl This is necessary + // in some situations as otherwise the lookups would fail to resolve the target + // symbol just because of e.g. a ternary. This is used in the signal input migration + // for host bindings. + type = options.ignoreNullability ? type.getNonNullableType() : type; + + const propSymbol = type.getProperty(propName); + if (propSymbol === undefined) { + return null; + } + symbol = propSymbol; + type = checker.getTypeOfSymbol(propSymbol); + } + + if (symbol === null) { + return null; + } + + return {symbol, type}; +} From ec94e1ecb0e63712748cce300a456bf1c5d5d510 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Tue, 3 Sep 2024 16:39:43 +0000 Subject: [PATCH 22/41] refactor(migrations): move reference signal input migration into dedicated file (#57645) Moves the rather complicated reference migration logic for the input migration into a separate method. This cleans up the logic and makes way for an additional complexity with regards to element bindings. PR Close #57645 --- .../src/passes/5_migrate_ts_references.ts | 119 +++--------------- .../standard_reference.ts | 116 +++++++++++++++++ 2 files changed, 136 insertions(+), 99 deletions(-) create mode 100644 packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/standard_reference.ts diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/5_migrate_ts_references.ts b/packages/core/schematics/migrations/signal-migration/src/passes/5_migrate_ts_references.ts index b7a6120ada53a..68931f1a0f733 100644 --- a/packages/core/schematics/migrations/signal-migration/src/passes/5_migrate_ts_references.ts +++ b/packages/core/schematics/migrations/signal-migration/src/passes/5_migrate_ts_references.ts @@ -6,16 +6,17 @@ * found in the LICENSE file at https://angular.io/license */ +import {AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; import ts from 'typescript'; +import {KnownInputs} from '../input_detection/known_inputs'; import {MigrationResult} from '../result'; -import {analyzeControlFlow} from '../flow_analysis'; -import {projectRelativePath, Replacement, TextUpdate} from '../../../../utils/tsurge/replacement'; import {InputUniqueKey} from '../utils/input_id'; import {isTsInputReference} from '../utils/input_reference'; -import {traverseAccess} from '../utils/traverse_access'; -import {KnownInputs} from '../input_detection/known_inputs'; import {UniqueNamesGenerator} from '../utils/unique_names'; -import {AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; +import { + migrateStandardTsReference, + NarrowableTsReference, +} from './migrate_ts_reference/standard_reference'; /** * Phase that migrates TypeScript input references to be signal compatible. @@ -48,7 +49,8 @@ export function pass5__migrateTypeScriptReferences( knownInputs: KnownInputs, projectDirAbsPath: AbsoluteFsPath, ) { - const tsReferences = new Map(); + const tsReferencesWithNarrowing = new Map(); + const seenIdentifiers = new WeakSet(); const nameGenerator = new UniqueNamesGenerator(); @@ -73,100 +75,19 @@ export function pass5__migrateTypeScriptReferences( } seenIdentifiers.add(reference.from.node); - if (!tsReferences.has(reference.target.key)) { - tsReferences.set(reference.target.key, { - accesses: [], - }); - } - tsReferences.get(reference.target.key)!.accesses.push(reference.from.node); - } - - // TODO: Consider checking/properly handling optional chaining and narrowing. - - for (const reference of tsReferences.values()) { - const controlFlowResult = analyzeControlFlow(reference.accesses, checker); - const idToSharedField = new Map(); - - for (const {id, originalNode, recommendedNode} of controlFlowResult) { - const sf = originalNode.getSourceFile(); + const targetKey = reference.target.key; - // Original node is preserved. No narrowing, and hence not shared. - // Unwrap the signal directly. - if (recommendedNode === 'preserve') { - // Append `()` to unwrap the signal. - result.replacements.push( - new Replacement( - projectRelativePath(sf, projectDirAbsPath), - new TextUpdate({ - position: originalNode.getEnd(), - end: originalNode.getEnd(), - toInsert: '()', - }), - ), - ); - continue; - } - - // This reference is shared with a previous reference. Replace the access - // with the temporary variable. - if (typeof recommendedNode === 'number') { - const replaceNode = traverseAccess(originalNode); - result.replacements.push( - new Replacement( - projectRelativePath(sf, projectDirAbsPath), - new TextUpdate({ - position: replaceNode.getStart(), - end: replaceNode.getEnd(), - // Extract the shared field name. - toInsert: idToSharedField.get(recommendedNode)!, - }), - ), - ); - continue; - } - - // Otherwise, we are creating a "shared reference" at the given node and - // block. - - // Iterate up the original node, until we hit the "recommended block" level. - // We then use the previous child as anchor for inserting. This allows us - // to insert right before the first reference in the container, at the proper - // block level— instead of always inserting at the beginning of the container. - let parent = originalNode.parent; - let previous: ts.Node = originalNode; - while (parent !== recommendedNode) { - previous = parent; - parent = parent.parent; - } - - const leadingSpace = ts.getLineAndCharacterOfPosition(sf, previous.getStart()); - - const replaceNode = traverseAccess(originalNode); - const fieldName = nameGenerator.generate(originalNode.text, previous); - - idToSharedField.set(id, fieldName); - - result.replacements.push( - new Replacement( - projectRelativePath(sf, projectDirAbsPath), - new TextUpdate({ - position: previous.getStart(), - end: previous.getStart(), - toInsert: `const ${fieldName} = ${replaceNode.getText()}();\n${' '.repeat(leadingSpace.character)}`, - }), - ), - ); - - result.replacements.push( - new Replacement( - projectRelativePath(sf, projectDirAbsPath), - new TextUpdate({ - position: replaceNode.getStart(), - end: replaceNode.getEnd(), - toInsert: fieldName, - }), - ), - ); + if (!tsReferencesWithNarrowing.has(targetKey)) { + tsReferencesWithNarrowing.set(targetKey, {accesses: []}); } + tsReferencesWithNarrowing.get(targetKey)!.accesses.push(reference.from.node); } + + migrateStandardTsReference( + tsReferencesWithNarrowing, + checker, + result, + nameGenerator, + projectDirAbsPath, + ); } diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/standard_reference.ts b/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/standard_reference.ts new file mode 100644 index 0000000000000..d44c9e299a223 --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/standard_reference.ts @@ -0,0 +1,116 @@ +/** + * @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.io/license + */ + +import ts from 'typescript'; +import {InputUniqueKey} from '../../utils/input_id'; +import {analyzeControlFlow} from '../../flow_analysis'; +import {MigrationResult} from '../../result'; +import {projectRelativePath, Replacement, TextUpdate} from '../../../../../utils/tsurge'; +import {AbsoluteFsPath} from '../../../../../../../compiler-cli'; +import {traverseAccess} from '../../utils/traverse_access'; +import {UniqueNamesGenerator} from '../../utils/unique_names'; + +export interface NarrowableTsReference { + accesses: ts.Identifier[]; +} + +export function migrateStandardTsReference( + tsReferencesWithNarrowing: Map, + checker: ts.TypeChecker, + result: MigrationResult, + nameGenerator: UniqueNamesGenerator, + projectDirAbsPath: AbsoluteFsPath, +) { + // TODO: Consider checking/properly handling optional chaining and narrowing. + for (const reference of tsReferencesWithNarrowing.values()) { + const controlFlowResult = analyzeControlFlow(reference.accesses, checker); + const idToSharedField = new Map(); + + for (const {id, originalNode, recommendedNode} of controlFlowResult) { + const sf = originalNode.getSourceFile(); + + // Original node is preserved. No narrowing, and hence not shared. + // Unwrap the signal directly. + if (recommendedNode === 'preserve') { + // Append `()` to unwrap the signal. + result.replacements.push( + new Replacement( + projectRelativePath(sf, projectDirAbsPath), + new TextUpdate({ + position: originalNode.getEnd(), + end: originalNode.getEnd(), + toInsert: '()', + }), + ), + ); + continue; + } + + // This reference is shared with a previous reference. Replace the access + // with the temporary variable. + if (typeof recommendedNode === 'number') { + const replaceNode = traverseAccess(originalNode); + result.replacements.push( + new Replacement( + projectRelativePath(sf, projectDirAbsPath), + new TextUpdate({ + position: replaceNode.getStart(), + end: replaceNode.getEnd(), + // Extract the shared field name. + toInsert: idToSharedField.get(recommendedNode)!, + }), + ), + ); + continue; + } + + // Otherwise, we are creating a "shared reference" at the given node and + // block. + + // Iterate up the original node, until we hit the "recommended block" level. + // We then use the previous child as anchor for inserting. This allows us + // to insert right before the first reference in the container, at the proper + // block level— instead of always inserting at the beginning of the container. + let parent = originalNode.parent; + let previous: ts.Node = originalNode; + while (parent !== recommendedNode) { + previous = parent; + parent = parent.parent; + } + + const leadingSpace = ts.getLineAndCharacterOfPosition(sf, previous.getStart()); + + const replaceNode = traverseAccess(originalNode); + const fieldName = nameGenerator.generate(originalNode.text, previous); + + idToSharedField.set(id, fieldName); + + result.replacements.push( + new Replacement( + projectRelativePath(sf, projectDirAbsPath), + new TextUpdate({ + position: previous.getStart(), + end: previous.getStart(), + toInsert: `const ${fieldName} = ${replaceNode.getText()}();\n${' '.repeat(leadingSpace.character)}`, + }), + ), + ); + + result.replacements.push( + new Replacement( + projectRelativePath(sf, projectDirAbsPath), + new TextUpdate({ + position: replaceNode.getStart(), + end: replaceNode.getEnd(), + toInsert: fieldName, + }), + ), + ); + } + } +} From 61dfb2b03f3a5a95cd61712359f6be0490c6ff84 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Tue, 3 Sep 2024 16:42:18 +0000 Subject: [PATCH 23/41] refactor(migrations): share `ts.Printer` for signal input migration (#57645) Instead of re-creating printers everywhere, we should re-use the same printer throughout the migration. PR Close #57645 --- .../src/convert-input/convert_to_signal.ts | 8 ++++---- .../src/passes/6_migrate_input_declarations.ts | 2 +- .../src/passes/9_migrate_ts_type_references.ts | 2 +- .../schematics/migrations/signal-migration/src/result.ts | 3 +++ 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/core/schematics/migrations/signal-migration/src/convert-input/convert_to_signal.ts b/packages/core/schematics/migrations/signal-migration/src/convert-input/convert_to_signal.ts index 72a71e7b81111..cdf5759ba73fd 100644 --- a/packages/core/schematics/migrations/signal-migration/src/convert-input/convert_to_signal.ts +++ b/packages/core/schematics/migrations/signal-migration/src/convert-input/convert_to_signal.ts @@ -13,8 +13,7 @@ import {ConvertInputPreparation} from './prepare_and_check'; import {DecoratorInputTransform} from '@angular/compiler-cli/src/ngtsc/metadata'; import {ImportManager} from '@angular/compiler-cli/src/ngtsc/translator'; import {removeFromUnionIfPossible} from '../utils/remove_from_union'; - -const printer = ts.createPrinter({newLine: ts.NewLineKind.LineFeed}); +import {MigrationResult} from '../result'; // TODO: Consider initializations inside the constructor. Those are not migrated right now // though, as they are writes. @@ -36,6 +35,7 @@ export function convertToSignalInput( }: ConvertInputPreparation, checker: ts.TypeChecker, importManager: ImportManager, + result: MigrationResult, ): string { let optionsLiteral: ts.ObjectLiteralExpression | null = null; @@ -124,7 +124,7 @@ export function convertToSignalInput( modifiersWithoutInputDecorator.push(ts.factory.createModifier(ts.SyntaxKind.ReadonlyKeyword)); } - const result = ts.factory.createPropertyDeclaration( + const newNode = ts.factory.createPropertyDeclaration( modifiersWithoutInputDecorator, node.name, undefined, @@ -132,7 +132,7 @@ export function convertToSignalInput( inputInitializer, ); - return printer.printNode(ts.EmitHint.Unspecified, result, node.getSourceFile()); + return result.printer.printNode(ts.EmitHint.Unspecified, newNode, node.getSourceFile()); } /** diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/6_migrate_input_declarations.ts b/packages/core/schematics/migrations/signal-migration/src/passes/6_migrate_input_declarations.ts index a8b875fc2f3c0..038059e87b7c6 100644 --- a/packages/core/schematics/migrations/signal-migration/src/passes/6_migrate_input_declarations.ts +++ b/packages/core/schematics/migrations/signal-migration/src/passes/6_migrate_input_declarations.ts @@ -47,7 +47,7 @@ export function pass6__migrateInputDeclarations( new TextUpdate({ position: input.node.getStart(), end: input.node.getEnd(), - toInsert: convertToSignalInput(input.node, metadata, checker, importManager), + toInsert: convertToSignalInput(input.node, metadata, checker, importManager, result), }), ), ); diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/9_migrate_ts_type_references.ts b/packages/core/schematics/migrations/signal-migration/src/passes/9_migrate_ts_type_references.ts index bf7bbd5f0e3da..8727e9124aa18 100644 --- a/packages/core/schematics/migrations/signal-migration/src/passes/9_migrate_ts_type_references.ts +++ b/packages/core/schematics/migrations/signal-migration/src/passes/9_migrate_ts_type_references.ts @@ -63,7 +63,7 @@ export function pass9__migrateTypeScriptTypeReferences( new TextUpdate({ position: firstArg.getStart(), end: firstArg.getStart(), - toInsert: `${ts.createPrinter().printNode(ts.EmitHint.Unspecified, unwrapImportExpr, sf)}<`, + toInsert: `${result.printer.printNode(ts.EmitHint.Unspecified, unwrapImportExpr, sf)}<`, }), ), ); diff --git a/packages/core/schematics/migrations/signal-migration/src/result.ts b/packages/core/schematics/migrations/signal-migration/src/result.ts index 086e3397ad0ab..bd9b738aa34a8 100644 --- a/packages/core/schematics/migrations/signal-migration/src/result.ts +++ b/packages/core/schematics/migrations/signal-migration/src/result.ts @@ -23,10 +23,13 @@ import {Replacement} from '../../../utils/tsurge/replacement'; * - imports that may need to be updated. */ export class MigrationResult { + printer = ts.createPrinter({newLine: ts.NewLineKind.LineFeed}); + // May be `null` if the input cannot be converted. This is also // signified by an incompatibility- but the input is tracked here as it // still is a "source input". sourceInputs = new Map(); + references: InputReference[] = []; // Execution data From 0764981f3a2ebd6d50a64924531b1e279752158f Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Tue, 3 Sep 2024 16:44:25 +0000 Subject: [PATCH 24/41] refactor(migrations): add performance logging for input reference lookups (#57645) Adds logic to capture performance timings when resolving input references. This is useful for debugging and improving integration in the VSCode extension. PR Close #57645 --- .../passes/2_find_source_file_references.ts | 27 ++++++++++++++++++- .../src/utils/grouped_ts_ast_visitor.ts | 10 ++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/2_find_source_file_references.ts b/packages/core/schematics/migrations/signal-migration/src/passes/2_find_source_file_references.ts index 86db52298505c..33fef390a2c7f 100644 --- a/packages/core/schematics/migrations/signal-migration/src/passes/2_find_source_file_references.ts +++ b/packages/core/schematics/migrations/signal-migration/src/passes/2_find_source_file_references.ts @@ -53,7 +53,16 @@ export function pass2_IdentifySourceFileReferences( knownInputs, ); + const perfCounters = { + template: 0, + hostBindings: 0, + tsReferences: 0, + tsTypes: 0, + }; + const visitor = (node: ts.Node) => { + let lastTime = performance.now(); + if (ts.isClassDeclaration(node)) { identifyTemplateReferences( node, @@ -67,9 +76,17 @@ export function pass2_IdentifySourceFileReferences( result, knownInputs, ); + perfCounters.template += (performance.now() - lastTime) / 1000; + lastTime = performance.now(); + identifyHostBindingReferences(node, host, checker, reflector, result, knownInputs); + + perfCounters.hostBindings += (performance.now() - lastTime) / 1000; + lastTime = performance.now(); } + lastTime = performance.now(); + // find references, but do not capture: // (1) input declarations. // (2) binding element declarations. @@ -85,6 +102,8 @@ export function pass2_IdentifySourceFileReferences( }); } + perfCounters.tsReferences += (performance.now() - lastTime) / 1000; + lastTime = performance.now(); // Detect `Partial` references. // Those are relevant to be tracked as they may be updated in Catalyst to // unwrap signal inputs. Commonly people use `Partial` in Catalyst to type @@ -102,7 +121,13 @@ export function pass2_IdentifySourceFileReferences( target: partialDirectiveInCatalyst.targetClass, }); } + + perfCounters.tsTypes += (performance.now() - lastTime) / 1000; }; - groupedTsAstVisitor.register(visitor); + groupedTsAstVisitor.register(visitor, () => { + if (process.env['DEBUG'] === '1') { + console.info('Source file analysis performance', perfCounters); + } + }); } diff --git a/packages/core/schematics/migrations/signal-migration/src/utils/grouped_ts_ast_visitor.ts b/packages/core/schematics/migrations/signal-migration/src/utils/grouped_ts_ast_visitor.ts index a0be85f7153f5..9544a73c98eaa 100644 --- a/packages/core/schematics/migrations/signal-migration/src/utils/grouped_ts_ast_visitor.ts +++ b/packages/core/schematics/migrations/signal-migration/src/utils/grouped_ts_ast_visitor.ts @@ -17,6 +17,7 @@ import ts from 'typescript'; */ export class GroupedTsAstVisitor { private visitors: Array<(node: ts.Node) => void> = []; + private doneFns: Array<() => void> = []; constructor(private files: readonly ts.SourceFile[]) {} @@ -24,8 +25,11 @@ export class GroupedTsAstVisitor { insidePropertyDeclaration: null as ts.PropertyDeclaration | null, }; - register(visitor: (node: ts.Node) => void) { + register(visitor: (node: ts.Node) => void, done?: () => void) { this.visitors.push(visitor); + if (done !== undefined) { + this.doneFns.push(done); + } } execute() { @@ -45,5 +49,9 @@ export class GroupedTsAstVisitor { for (const file of this.files) { ts.forEachChild(file, visitor); } + + for (const doneFn of this.doneFns) { + doneFn(); + } } } From a777bee3de07a53de6f3e28f575df5e8ec533f9b Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Tue, 3 Sep 2024 16:50:29 +0000 Subject: [PATCH 25/41] refactor(migrations): improve element binding migration for input migration (#57645) Currently we detect element bindings as normal references and inside usages we simply unwrap its usages. This works, but breaks in situations like the following: - When the expressions are narrowed. Narrowing analysis does not support aliased inputs. E.g. `const {myInput: alias} = this`. We could add this, but it would complexify the logic. - When binding patterns deeply access value properties directly. E.g. `const {myInput: {value}} = this;` In addition, the current approach requires us to understand that aliases may point to inputs. This means we need to check all identifiers if they point to Angular inputs. We could optimize this, but it's much easier if we can simply assume that we only need to "verify" identifiers that have names of "known inputs". This would significantly speed up turnaround in the language service integration. In addition, it would be more _correct_, semantically to directly access the value of the input at object expansion, versus later. PR Close #57645 --- .../signal-migration/src/batch/extract.ts | 1 + .../passes/2_find_source_file_references.ts | 9 +- .../src/passes/5_migrate_ts_references.ts | 22 +- .../object_expansion_refs.ts | 214 ++++++++++++++++++ .../standard_reference.ts | 5 +- .../references/identify_ts_references.ts | 29 ++- .../src/utils/binding_elements.ts | 40 ++++ .../src/utils/input_reference.ts | 2 + .../test/golden-test/narrowing.ts | 23 ++ .../test/golden-test/object_expansion.ts | 11 + .../signal-migration/test/golden.txt | 53 ++++- .../test/golden_best_effort.txt | 53 ++++- 12 files changed, 434 insertions(+), 28 deletions(-) create mode 100644 packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/object_expansion_refs.ts create mode 100644 packages/core/schematics/migrations/signal-migration/src/utils/binding_elements.ts create mode 100644 packages/core/schematics/migrations/signal-migration/test/golden-test/narrowing.ts diff --git a/packages/core/schematics/migrations/signal-migration/src/batch/extract.ts b/packages/core/schematics/migrations/signal-migration/src/batch/extract.ts index 4ccd26e3a08a0..ae3da7e92986b 100644 --- a/packages/core/schematics/migrations/signal-migration/src/batch/extract.ts +++ b/packages/core/schematics/migrations/signal-migration/src/batch/extract.ts @@ -51,6 +51,7 @@ export function getCompilationUnitMetadata(knownInputs: KnownInputs, result: Mig fileId: r.from.fileId, node: {positionEndInFile: r.from.node.getEnd()}, isWrite: r.from.isWrite, + isPartOfElementBinding: r.from.isPartOfElementBinding, }, }; } else if (isHostBindingInputReference(r)) { diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/2_find_source_file_references.ts b/packages/core/schematics/migrations/signal-migration/src/passes/2_find_source_file_references.ts index 33fef390a2c7f..0ec8c44f40630 100644 --- a/packages/core/schematics/migrations/signal-migration/src/passes/2_find_source_file_references.ts +++ b/packages/core/schematics/migrations/signal-migration/src/passes/2_find_source_file_references.ts @@ -87,15 +87,10 @@ export function pass2_IdentifySourceFileReferences( lastTime = performance.now(); - // find references, but do not capture: - // (1) input declarations. - // (2) binding element declarations. + // find references, but do not capture input declarations itself. if ( ts.isIdentifier(node) && - !( - (isInputContainerNode(node.parent) && node.parent.name === node) || - ts.isBindingElement(node.parent) - ) + !(isInputContainerNode(node.parent) && node.parent.name === node) ) { identifyPotentialTypeScriptReference(node, host, checker, knownInputs, result, { debugElComponentInstanceTracker, diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/5_migrate_ts_references.ts b/packages/core/schematics/migrations/signal-migration/src/passes/5_migrate_ts_references.ts index 68931f1a0f733..a1e0e23a477f6 100644 --- a/packages/core/schematics/migrations/signal-migration/src/passes/5_migrate_ts_references.ts +++ b/packages/core/schematics/migrations/signal-migration/src/passes/5_migrate_ts_references.ts @@ -13,6 +13,10 @@ import {MigrationResult} from '../result'; import {InputUniqueKey} from '../utils/input_id'; import {isTsInputReference} from '../utils/input_reference'; import {UniqueNamesGenerator} from '../utils/unique_names'; +import { + migrateBindingElementInputReference, + IdentifierOfBindingElement, +} from './migrate_ts_reference/object_expansion_refs'; import { migrateStandardTsReference, NarrowableTsReference, @@ -50,6 +54,7 @@ export function pass5__migrateTypeScriptReferences( projectDirAbsPath: AbsoluteFsPath, ) { const tsReferencesWithNarrowing = new Map(); + const tsReferencesInBindingElements = new Set(); const seenIdentifiers = new WeakSet(); const nameGenerator = new UniqueNamesGenerator(); @@ -77,12 +82,23 @@ export function pass5__migrateTypeScriptReferences( const targetKey = reference.target.key; - if (!tsReferencesWithNarrowing.has(targetKey)) { - tsReferencesWithNarrowing.set(targetKey, {accesses: []}); + if (reference.from.isPartOfElementBinding) { + tsReferencesInBindingElements.add(reference.from.node as IdentifierOfBindingElement); + } else { + if (!tsReferencesWithNarrowing.has(targetKey)) { + tsReferencesWithNarrowing.set(targetKey, {accesses: []}); + } + tsReferencesWithNarrowing.get(targetKey)!.accesses.push(reference.from.node); } - tsReferencesWithNarrowing.get(targetKey)!.accesses.push(reference.from.node); } + migrateBindingElementInputReference( + tsReferencesInBindingElements, + projectDirAbsPath, + nameGenerator, + result, + ); + migrateStandardTsReference( tsReferencesWithNarrowing, checker, diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/object_expansion_refs.ts b/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/object_expansion_refs.ts new file mode 100644 index 0000000000000..d884665d3a9cb --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/object_expansion_refs.ts @@ -0,0 +1,214 @@ +/** + * @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.io/license + */ + +import ts from 'typescript'; +import { + projectRelativePath, + ProjectRelativePath, + Replacement, + TextUpdate, +} from '../../../../../utils/tsurge/replacement'; +import {getBindingElementDeclaration} from '../../utils/binding_elements'; +import {AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; +import {UniqueNamesGenerator} from '../../utils/unique_names'; +import assert from 'assert'; +import {MigrationResult} from '../../result'; + +/** An identifier part of a binding element. */ +export interface IdentifierOfBindingElement extends ts.Identifier { + parent: ts.BindingElement; +} + +/** + * Migrates a binding element that refers to an Angular input. + * + * E.g. `const {myInput} = this`. + * + * For references in binding elements, we extract the element into a variable + * where we unwrap the input. This ensures narrowing naturally works in subsequent + * places, and we also don't need to detect potential aliases. + * + * ```ts + * const {myInput} = this; + * // turns into + * const {myInput: myInputValue} = this; + * const myInput = myInputValue(); + * ``` + */ +export function migrateBindingElementInputReference( + tsReferencesInBindingElements: Set, + projectDirAbsPath: AbsoluteFsPath, + nameGenerator: UniqueNamesGenerator, + result: MigrationResult, +) { + for (const reference of tsReferencesInBindingElements) { + const bindingElement = reference.parent; + const bindingDecl = getBindingElementDeclaration(bindingElement); + + const sourceFile = bindingElement.getSourceFile(); + const filePath = projectRelativePath(sourceFile.fileName, projectDirAbsPath); + + const inputFieldName = bindingElement.propertyName ?? bindingElement.name; + assert( + !ts.isObjectBindingPattern(inputFieldName) && !ts.isArrayBindingPattern(inputFieldName), + 'Property of binding element cannot be another pattern.', + ); + + const tmpName: string | undefined = nameGenerator.generate(reference.text, bindingElement); + // Only use the temporary name, if really needed. A temporary name is needed if + // the input field simply aliased via the binding element, or if the exposed identifier + // is a string-literal like. + const useTmpName = + !ts.isObjectBindingPattern(bindingElement.name) || !ts.isIdentifier(inputFieldName); + + const propertyName = useTmpName ? inputFieldName : undefined; + const exposedName = useTmpName ? ts.factory.createIdentifier(tmpName) : inputFieldName; + const newBinding = ts.factory.updateBindingElement( + bindingElement, + bindingElement.dotDotDotToken, + propertyName, + exposedName, + bindingElement.initializer, + ); + + const temporaryVariableReplacements = insertTemporaryVariableForBindingElement( + bindingDecl, + filePath, + `const ${bindingElement.name.getText()} = ${tmpName}();`, + ); + if (temporaryVariableReplacements === null) { + console.error(`Could not migrate reference ${reference.text} in ${filePath}`); + continue; + } + + result.replacements.push( + new Replacement( + filePath, + new TextUpdate({ + position: bindingElement.getStart(), + end: bindingElement.getEnd(), + toInsert: result.printer.printNode(ts.EmitHint.Unspecified, newBinding, sourceFile), + }), + ), + ...temporaryVariableReplacements, + ); + } +} + +/** + * Inserts the given code snippet after the given variable or + * parameter declaration. + * + * If this is a parameter of an arrow function, a block may be + * added automatically. + */ +function insertTemporaryVariableForBindingElement( + expansionDecl: ts.VariableDeclaration | ts.ParameterDeclaration, + filePath: ProjectRelativePath, + toInsert: string, +): Replacement[] | null { + const sf = expansionDecl.getSourceFile(); + const parent = expansionDecl.parent; + + // The snippet is simply inserted after the variable declaration. + // The other case of a variable declaration inside a catch clause is handled + // below. + if (ts.isVariableDeclaration(expansionDecl) && ts.isVariableDeclarationList(parent)) { + const leadingSpaceCount = ts.getLineAndCharacterOfPosition(sf, parent.getStart()).character; + const leadingSpace = ' '.repeat(leadingSpaceCount); + const statement: ts.Statement = parent.parent; + + return [ + new Replacement( + filePath, + new TextUpdate({ + position: statement.getEnd(), + end: statement.getEnd(), + toInsert: `\n${leadingSpace}${toInsert}`, + }), + ), + ]; + } + + // If we are dealing with a object expansion inside a parameter of + // a function-like declaration w/ block, add the variable as the first + // node inside the block. + const bodyBlock = getBodyBlockOfNode(parent); + if (bodyBlock !== null) { + const firstElementInBlock = bodyBlock.statements[0] as ts.Statement | undefined; + const spaceReferenceNode = firstElementInBlock ?? bodyBlock; + const spaceOffset = firstElementInBlock !== undefined ? 0 : 2; + + const leadingSpaceCount = + ts.getLineAndCharacterOfPosition(sf, spaceReferenceNode.getStart()).character + spaceOffset; + const leadingSpace = ' '.repeat(leadingSpaceCount); + + return [ + new Replacement( + filePath, + new TextUpdate({ + position: bodyBlock.getStart() + 1, + end: bodyBlock.getStart() + 1, + toInsert: `\n${leadingSpace}${toInsert}`, + }), + ), + ]; + } + + // Other cases where we see an arrow function without a block. + // We need to create one now. + if (ts.isArrowFunction(parent) && !ts.isBlock(parent.body)) { + // For indentation, we traverse up and find the earliest statement. + // This node is most of the time a good candidate for acceptable + // indentation of a new block. + const spacingNode = ts.findAncestor(parent, ts.isStatement) ?? parent.parent; + const {character} = ts.getLineAndCharacterOfPosition(sf, spacingNode.getStart()); + const blockSpace = ' '.repeat(character); + const contentSpace = ' '.repeat(character + 2); + + return [ + new Replacement( + filePath, + new TextUpdate({ + position: parent.body.getStart(), + end: parent.body.getEnd(), + toInsert: `{\n${contentSpace}${toInsert}\n${contentSpace}return ${parent.body.getText()};`, + }), + ), + new Replacement( + filePath, + new TextUpdate({ + position: parent.body.getEnd(), + end: parent.body.getEnd(), + toInsert: `\n${blockSpace}}`, + }), + ), + ]; + } + + return null; +} + +/** Gets the body block of a given node, if available. */ +function getBodyBlockOfNode(node: ts.Node): ts.Block | null { + if ( + (ts.isMethodDeclaration(node) || + ts.isFunctionDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isConstructorDeclaration(node) || + ts.isArrowFunction(node)) && + node.body !== undefined && + ts.isBlock(node.body) + ) { + return node.body; + } + if (ts.isCatchClause(node.parent)) { + return node.parent.block; + } + return null; +} diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/standard_reference.ts b/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/standard_reference.ts index d44c9e299a223..81dd78795dc1e 100644 --- a/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/standard_reference.ts +++ b/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/standard_reference.ts @@ -11,7 +11,7 @@ import {InputUniqueKey} from '../../utils/input_id'; import {analyzeControlFlow} from '../../flow_analysis'; import {MigrationResult} from '../../result'; import {projectRelativePath, Replacement, TextUpdate} from '../../../../../utils/tsurge'; -import {AbsoluteFsPath} from '../../../../../../../compiler-cli'; +import {AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; import {traverseAccess} from '../../utils/traverse_access'; import {UniqueNamesGenerator} from '../../utils/unique_names'; @@ -83,6 +83,9 @@ export function migrateStandardTsReference( parent = parent.parent; } + if (ts.isArrowFunction(recommendedNode)) { + } + const leadingSpace = ts.getLineAndCharacterOfPosition(sf, previous.getStart()); const replaceNode = traverseAccess(originalNode); diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/references/identify_ts_references.ts b/packages/core/schematics/migrations/signal-migration/src/passes/references/identify_ts_references.ts index bc287bad74d62..15f597a3bc28f 100644 --- a/packages/core/schematics/migrations/signal-migration/src/passes/references/identify_ts_references.ts +++ b/packages/core/schematics/migrations/signal-migration/src/passes/references/identify_ts_references.ts @@ -17,6 +17,8 @@ import {InputReferenceKind} from '../../utils/input_reference'; import {traverseAccess} from '../../utils/traverse_access'; import {unwrapParent} from '../../utils/unwrap_parent'; import {writeBinaryOperators} from '../../utils/write_operators'; +import {resolveBindingElement} from '../../utils/binding_elements'; +import {lookupPropertyAccess} from '../../../../../utils/tsurge/helpers/ast/lookup_property_access'; /** * Checks whether given TypeScript reference refers to an Angular input, and captures @@ -31,20 +33,28 @@ export function identifyPotentialTypeScriptReference( advisors: { debugElComponentInstanceTracker: DebugElementComponentInstance; }, -) { - let target = checker.getSymbolAtLocation(node); +): void { + let target: ts.Symbol | undefined = undefined; // Resolve binding elements to their declaration symbol. // Commonly inputs are accessed via object expansion. e.g. `const {input} = this;`. - if (target?.declarations?.[0] && ts.isBindingElement(target?.declarations[0])) { - const bindingElement = target.declarations[0]; - const bindingParent = bindingElement.parent; - const bindingType = checker.getTypeAtLocation(bindingParent); - const bindingName = bindingElement.propertyName ?? bindingElement.name; + if (ts.isBindingElement(node.parent)) { + // Skip binding elements that are using spread. + if (node.parent.dotDotDotToken !== undefined) { + return; + } - if (ts.isIdentifier(bindingName) && bindingType.getProperty(bindingName.text)) { - target = bindingType.getProperty(bindingName.text); + const bindingInfo = resolveBindingElement(node.parent); + if (bindingInfo === null) { + // The declaration could not be resolved. Skip analyzing this. + return; } + + const bindingType = checker.getTypeAtLocation(bindingInfo.pattern); + const resolved = lookupPropertyAccess(checker, bindingType, [bindingInfo.propertyName]); + target = resolved?.symbol; + } else { + target = checker.getSymbolAtLocation(node); } noTargetSymbolCheck: if (target === undefined) { @@ -86,6 +96,7 @@ export function identifyPotentialTypeScriptReference( node, fileId: host.fileToId(node.getSourceFile()), isWrite: isWriteReference, + isPartOfElementBinding: ts.isBindingElement(node.parent), }, target: targetInput?.descriptor, }); diff --git a/packages/core/schematics/migrations/signal-migration/src/utils/binding_elements.ts b/packages/core/schematics/migrations/signal-migration/src/utils/binding_elements.ts new file mode 100644 index 0000000000000..80858b9282d5e --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/src/utils/binding_elements.ts @@ -0,0 +1,40 @@ +/** + * @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.io/license + */ + +import ts from 'typescript'; + +/** Gets the pattern and property name for a given binding element. */ +export function resolveBindingElement(node: ts.BindingElement): { + pattern: ts.BindingPattern; + propertyName: string; +} | null { + const name = node.propertyName ?? node.name; + + // If we are discovering a non-analyzable element in the path, abort. + if (!ts.isStringLiteralLike(name) && !ts.isIdentifier(name)) { + return null; + } + + return { + pattern: node.parent, + propertyName: name.text, + }; +} + +/** Gets the declaration node of the given binding element. */ +export function getBindingElementDeclaration( + node: ts.BindingElement, +): ts.VariableDeclaration | ts.ParameterDeclaration { + while (true) { + if (ts.isBindingElement(node.parent.parent)) { + node = node.parent.parent; + } else { + return node.parent.parent; + } + } +} diff --git a/packages/core/schematics/migrations/signal-migration/src/utils/input_reference.ts b/packages/core/schematics/migrations/signal-migration/src/utils/input_reference.ts index c137cece502bf..0a54621521610 100644 --- a/packages/core/schematics/migrations/signal-migration/src/utils/input_reference.ts +++ b/packages/core/schematics/migrations/signal-migration/src/utils/input_reference.ts @@ -67,6 +67,8 @@ export interface TsInputReference { node: ts.Identifier; /** Whether the reference is a write. */ isWrite: boolean; + /** Whether the reference is part of an element binding */ + isPartOfElementBinding: boolean; }; /** Target input addressed by the reference. */ target: InputDescriptor; diff --git a/packages/core/schematics/migrations/signal-migration/test/golden-test/narrowing.ts b/packages/core/schematics/migrations/signal-migration/test/golden-test/narrowing.ts new file mode 100644 index 0000000000000..adc60c53a177d --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/test/golden-test/narrowing.ts @@ -0,0 +1,23 @@ +// tslint:disable + +import {Input, Directive} from '@angular/core'; + +@Directive() +export class Narrowing { + @Input() name: string | undefined = undefined; + + narrowingArrowFn() { + [this].map((x) => x.name && x.name.charAt(0)); + } + + narrowingObjectExpansion() { + [this].map(({name}) => name && name.charAt(0)); + } + + narrowingNormalThenObjectExpansion() { + if (this.name) { + const {charAt} = this.name; + charAt(0); + } + } +} diff --git a/packages/core/schematics/migrations/signal-migration/test/golden-test/object_expansion.ts b/packages/core/schematics/migrations/signal-migration/test/golden-test/object_expansion.ts index adf8adb76610b..b789ff23ee8c7 100644 --- a/packages/core/schematics/migrations/signal-migration/test/golden-test/object_expansion.ts +++ b/packages/core/schematics/migrations/signal-migration/test/golden-test/object_expansion.ts @@ -11,4 +11,15 @@ export class ObjectExpansion { bla.charAt(0); } + + deeperExpansion() { + const { + bla: {charAt}, + } = this; + charAt(0); + } + + expansionAsParameter({bla} = this) { + bla.charAt(0); + } } diff --git a/packages/core/schematics/migrations/signal-migration/test/golden.txt b/packages/core/schematics/migrations/signal-migration/test/golden.txt index 11e06015b7e85..0caca04bd34c8 100644 --- a/packages/core/schematics/migrations/signal-migration/test/golden.txt +++ b/packages/core/schematics/migrations/signal-migration/test/golden.txt @@ -514,9 +514,10 @@ class IndexAccessInput { readonly items = input([]); bla() { - const {items} = this; + const {items: itemsValue} = this; + const items = itemsValue(); - items()[0].charAt(0); + items[0].charAt(0); } } @@@@@@ index_spec.ts @@@@@@ @@ -693,6 +694,36 @@ export class TestCmp { return v.x; } } +@@@@@@ narrowing.ts @@@@@@ + +// tslint:disable + +import { Directive, input } from '@angular/core'; + +@Directive() +export class Narrowing { + readonly name = input(); + + narrowingArrowFn() { + const name = x.name(); + [this].map((x) => name && name.charAt(0)); + } + + narrowingObjectExpansion() { + [this].map(({name: nameValue}) => { + const name = nameValue(); + return name && name.charAt(0); + }); + } + + narrowingNormalThenObjectExpansion() { + const name = this.name(); + if (name) { + const {charAt} = name; + charAt(0); + } + } +} @@@@@@ nested_template_prop_access.ts @@@@@@ // tslint:disable @@ -741,9 +772,23 @@ export class ObjectExpansion { readonly bla = input(''); expansion() { - const {bla} = this; + const {bla: blaValue} = this; + const bla = blaValue(); + + bla.charAt(0); + } + + deeperExpansion() { + const { + bla, + } = this; + const {charAt} = bla(); + charAt(0); + } - bla().charAt(0); + expansionAsParameter({bla: blaValue} = this) { + const bla = blaValue(); + bla.charAt(0); } } @@@@@@ optimize_test.ts @@@@@@ diff --git a/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt b/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt index dbb106929a505..81beb612aa0d0 100644 --- a/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt +++ b/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt @@ -514,9 +514,10 @@ class IndexAccessInput { readonly items = input([]); bla() { - const {items} = this; + const {items: itemsValue} = this; + const items = itemsValue(); - items()[0].charAt(0); + items[0].charAt(0); } } @@@@@@ index_spec.ts @@@@@@ @@ -693,6 +694,36 @@ export class TestCmp { return v.x; } } +@@@@@@ narrowing.ts @@@@@@ + +// tslint:disable + +import { Directive, input } from '@angular/core'; + +@Directive() +export class Narrowing { + readonly name = input(); + + narrowingArrowFn() { + const name = x.name(); + [this].map((x) => name && name.charAt(0)); + } + + narrowingObjectExpansion() { + [this].map(({name: nameValue}) => { + const name = nameValue(); + return name && name.charAt(0); + }); + } + + narrowingNormalThenObjectExpansion() { + const name = this.name(); + if (name) { + const {charAt} = name; + charAt(0); + } + } +} @@@@@@ nested_template_prop_access.ts @@@@@@ // tslint:disable @@ -741,9 +772,23 @@ export class ObjectExpansion { readonly bla = input(''); expansion() { - const {bla} = this; + const {bla: blaValue} = this; + const bla = blaValue(); + + bla.charAt(0); + } + + deeperExpansion() { + const { + bla, + } = this; + const {charAt} = bla(); + charAt(0); + } - bla().charAt(0); + expansionAsParameter({bla: blaValue} = this) { + const bla = blaValue(); + bla.charAt(0); } } @@@@@@ optimize_test.ts @@@@@@ From a2e4ee0cb3d40cadc05e28d58b06853973944456 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 3 Sep 2024 21:17:33 +0200 Subject: [PATCH 26/41] feat(compiler): add diagnostic for unused standalone imports (#57605) Adds a new diagnostic that will report cases where a declaration is in the `imports` array, but isn't being used anywhere. The diagnostic is reported as a warning by default and can be controlled using the following option in the tsconfig: ``` { "angularCompilerOptions": { "extendedDiagnostics": { "checks": { "unusedStandaloneImports": "suppress" } } } } ``` **Note:** I'll look into a codefix for the language service in a follow-up. Fixes #46766. PR Close #57605 --- adev/src/app/sub-navigation-data.ts | 5 + .../reference/extended-diagnostics/NG8113.md | 48 +++ .../extended-diagnostics/overview.md | 1 + .../public-api/compiler-cli/error_code.api.md | 1 + .../extended_template_diagnostic_name.api.md | 4 +- .../annotations/component/src/handler.ts | 1 + .../annotations/directive/src/handler.ts | 1 + .../src/ngtsc/core/src/compiler.ts | 13 +- .../src/ngtsc/diagnostics/src/error.ts | 3 +- .../src/ngtsc/diagnostics/src/error_code.ts | 5 + .../src/extended_template_diagnostic_name.ts | 1 + .../src/ngtsc/metadata/src/api.ts | 5 + .../src/ngtsc/metadata/src/dts.ts | 1 + .../src/ngtsc/scope/test/local_spec.ts | 1 + .../src/ngtsc/typecheck/api/api.ts | 7 + .../src/ngtsc/typecheck/extended/index.ts | 1 + .../typecheck/test/type_check_block_spec.ts | 1 + .../src/ngtsc/typecheck/testing/index.ts | 5 + .../src/ngtsc/validation/BUILD.bazel | 1 + .../src/ngtsc/validation/src/config.ts | 13 + .../rules/unused_standalone_imports_rule.ts | 140 ++++++++ .../validation/src/source_file_validator.ts | 20 +- .../test/ngtsc/standalone_spec.ts | 2 +- .../test/ngtsc/template_typecheck_spec.ts | 302 ++++++++++++++++++ 24 files changed, 576 insertions(+), 6 deletions(-) create mode 100644 adev/src/content/reference/extended-diagnostics/NG8113.md create mode 100644 packages/compiler-cli/src/ngtsc/validation/src/config.ts create mode 100644 packages/compiler-cli/src/ngtsc/validation/src/rules/unused_standalone_imports_rule.ts diff --git a/adev/src/app/sub-navigation-data.ts b/adev/src/app/sub-navigation-data.ts index c46b6e5da8b78..2f71db14394e6 100644 --- a/adev/src/app/sub-navigation-data.ts +++ b/adev/src/app/sub-navigation-data.ts @@ -1368,6 +1368,11 @@ const REFERENCE_SUB_NAVIGATION_DATA: NavigationItem[] = [ path: 'extended-diagnostics/NG8111', contentPath: 'reference/extended-diagnostics/NG8111', }, + { + label: 'NG8113: Unused Standalone Imports', + path: 'extended-diagnostics/NG8113', + contentPath: 'reference/extended-diagnostics/NG8113', + }, ], }, { diff --git a/adev/src/content/reference/extended-diagnostics/NG8113.md b/adev/src/content/reference/extended-diagnostics/NG8113.md new file mode 100644 index 0000000000000..92a50a251cd8d --- /dev/null +++ b/adev/src/content/reference/extended-diagnostics/NG8113.md @@ -0,0 +1,48 @@ +# Unused Standalone Imports + +This diagnostic detects cases where the `imports` array of a `@Component` contains symbols that +aren't used within the template. + + + +@Component({ + imports: [UsedDirective, UnusedPipe] +}) +class AwesomeCheckbox {} + + + +## What's wrong with that? + +The unused imports add unnecessary noise to your code and can increase your compilation time. + +## What should I do instead? + +Delete the unused import. + + + +@Component({ + imports: [UsedDirective] +}) +class AwesomeCheckbox {} + + + +## What if I can't avoid this? + +This diagnostic can be disabled by editing the project's `tsconfig.json` file: + + +{ + "angularCompilerOptions": { + "extendedDiagnostics": { + "checks": { + "unusedStandaloneImports": "suppress" + } + } + } +} + + +See [extended diagnostic configuration](extended-diagnostics#configuration) for more info. diff --git a/adev/src/content/reference/extended-diagnostics/overview.md b/adev/src/content/reference/extended-diagnostics/overview.md index d64df7a3614b9..94714804e9d96 100644 --- a/adev/src/content/reference/extended-diagnostics/overview.md +++ b/adev/src/content/reference/extended-diagnostics/overview.md @@ -20,6 +20,7 @@ Currently, Angular supports the following extended diagnostics: | `NG8108` | [`skipHydrationNotStatic`](extended-diagnostics/NG8108) | | `NG8109` | [`interpolatedSignalNotInvoked`](extended-diagnostics/NG8109) | | `NG8111` | [`uninvokedFunctionInEventBinding`](extended-diagnostics/NG8111) | +| `NG8113` | [`unusedStandaloneImports`](extended-diagnostics/NG8113) | ## Configuration diff --git a/goldens/public-api/compiler-cli/error_code.api.md b/goldens/public-api/compiler-cli/error_code.api.md index 7c307e96f2ff0..011fcd785e62c 100644 --- a/goldens/public-api/compiler-cli/error_code.api.md +++ b/goldens/public-api/compiler-cli/error_code.api.md @@ -106,6 +106,7 @@ export enum ErrorCode { UNINVOKED_FUNCTION_IN_EVENT_BINDING = 8111, UNSUPPORTED_INITIALIZER_API_USAGE = 8110, UNUSED_LET_DECLARATION = 8112, + UNUSED_STANDALONE_IMPORTS = 8113, // (undocumented) VALUE_HAS_WRONG_TYPE = 1010, // (undocumented) diff --git a/goldens/public-api/compiler-cli/extended_template_diagnostic_name.api.md b/goldens/public-api/compiler-cli/extended_template_diagnostic_name.api.md index 36777b64fa9fe..ee99d7d584756 100644 --- a/goldens/public-api/compiler-cli/extended_template_diagnostic_name.api.md +++ b/goldens/public-api/compiler-cli/extended_template_diagnostic_name.api.md @@ -29,7 +29,9 @@ export enum ExtendedTemplateDiagnosticName { // (undocumented) UNINVOKED_FUNCTION_IN_EVENT_BINDING = "uninvokedFunctionInEventBinding", // (undocumented) - UNUSED_LET_DECLARATION = "unusedLetDeclaration" + UNUSED_LET_DECLARATION = "unusedLetDeclaration", + // (undocumented) + UNUSED_STANDALONE_IMPORTS = "unusedStandaloneImports" } // (No @packageDocumentation comment for this package) diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts index 24b7c3b2456a1..287fa1150619f 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts @@ -902,6 +902,7 @@ export class ComponentDecoratorHandler isStandalone: analysis.meta.isStandalone, isSignal: analysis.meta.isSignal, imports: analysis.resolvedImports, + rawImports: analysis.rawImports, deferredImports: analysis.resolvedDeferredImports, animationTriggerNames: analysis.animationTriggerNames, schemas: analysis.schemas, diff --git a/packages/compiler-cli/src/ngtsc/annotations/directive/src/handler.ts b/packages/compiler-cli/src/ngtsc/annotations/directive/src/handler.ts index fcf1861a5234a..8b03f1d7ba7b7 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/directive/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/directive/src/handler.ts @@ -270,6 +270,7 @@ export class DirectiveDecoratorHandler isStandalone: analysis.meta.isStandalone, isSignal: analysis.meta.isSignal, imports: null, + rawImports: null, deferredImports: null, schemas: null, ngContentSelectors: null, diff --git a/packages/compiler-cli/src/ngtsc/core/src/compiler.ts b/packages/compiler-cli/src/ngtsc/core/src/compiler.ts index 441293dbd2a99..b8f2ded863b5e 100644 --- a/packages/compiler-cli/src/ngtsc/core/src/compiler.ts +++ b/packages/compiler-cli/src/ngtsc/core/src/compiler.ts @@ -1037,6 +1037,8 @@ export class NgCompiler { suggestionsForSuboptimalTypeInference: this.enableTemplateTypeChecker && !strictTemplates, controlFlowPreventingContentProjection: this.options.extendedDiagnostics?.defaultCategory || DiagnosticCategoryLabel.Warning, + unusedStandaloneImports: + this.options.extendedDiagnostics?.defaultCategory || DiagnosticCategoryLabel.Warning, allowSignalsInTwoWayBindings, }; } else { @@ -1069,6 +1071,8 @@ export class NgCompiler { suggestionsForSuboptimalTypeInference: false, controlFlowPreventingContentProjection: this.options.extendedDiagnostics?.defaultCategory || DiagnosticCategoryLabel.Warning, + unusedStandaloneImports: + this.options.extendedDiagnostics?.defaultCategory || DiagnosticCategoryLabel.Warning, allowSignalsInTwoWayBindings, }; } @@ -1114,6 +1118,10 @@ export class NgCompiler { typeCheckingConfig.controlFlowPreventingContentProjection = this.options.extendedDiagnostics.checks.controlFlowPreventingContentProjection; } + if (this.options.extendedDiagnostics?.checks?.unusedStandaloneImports !== undefined) { + typeCheckingConfig.unusedStandaloneImports = + this.options.extendedDiagnostics.checks.unusedStandaloneImports; + } return typeCheckingConfig; } @@ -1541,11 +1549,12 @@ export class NgCompiler { }, ); + const typeCheckingConfig = this.getTypeCheckingConfig(); const templateTypeChecker = new TemplateTypeCheckerImpl( this.inputProgram, notifyingDriver, traitCompiler, - this.getTypeCheckingConfig(), + typeCheckingConfig, refEmitter, reflector, this.adapter, @@ -1576,7 +1585,7 @@ export class NgCompiler { const sourceFileValidator = this.constructionDiagnostics.length === 0 - ? new SourceFileValidator(reflector, importTracker) + ? new SourceFileValidator(reflector, importTracker, templateTypeChecker, typeCheckingConfig) : null; return { diff --git a/packages/compiler-cli/src/ngtsc/diagnostics/src/error.ts b/packages/compiler-cli/src/ngtsc/diagnostics/src/error.ts index 5902c44e8abaf..1eb0c48588817 100644 --- a/packages/compiler-cli/src/ngtsc/diagnostics/src/error.ts +++ b/packages/compiler-cli/src/ngtsc/diagnostics/src/error.ts @@ -50,10 +50,11 @@ export function makeDiagnostic( node: ts.Node, messageText: string | ts.DiagnosticMessageChain, relatedInformation?: ts.DiagnosticRelatedInformation[], + category: ts.DiagnosticCategory = ts.DiagnosticCategory.Error, ): ts.DiagnosticWithLocation { node = ts.getOriginalNode(node); return { - category: ts.DiagnosticCategory.Error, + category, code: ngErrorCode(code), file: ts.getOriginalNode(node).getSourceFile(), start: node.getStart(undefined, false), diff --git a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts index 14584d91f7783..e2fb34ff493df 100644 --- a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts +++ b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts @@ -508,6 +508,11 @@ export enum ErrorCode { */ UNUSED_LET_DECLARATION = 8112, + /** + * A symbol referenced in `@Component.imports` isn't being used within the template. + */ + UNUSED_STANDALONE_IMPORTS = 8113, + /** * The template type-checking engine would need to generate an inline type check block for a * component, but the current type-checking environment doesn't support it. diff --git a/packages/compiler-cli/src/ngtsc/diagnostics/src/extended_template_diagnostic_name.ts b/packages/compiler-cli/src/ngtsc/diagnostics/src/extended_template_diagnostic_name.ts index 1b968e610b136..7b847e70946de 100644 --- a/packages/compiler-cli/src/ngtsc/diagnostics/src/extended_template_diagnostic_name.ts +++ b/packages/compiler-cli/src/ngtsc/diagnostics/src/extended_template_diagnostic_name.ts @@ -28,4 +28,5 @@ export enum ExtendedTemplateDiagnosticName { INTERPOLATED_SIGNAL_NOT_INVOKED = 'interpolatedSignalNotInvoked', CONTROL_FLOW_PREVENTING_CONTENT_PROJECTION = 'controlFlowPreventingContentProjection', UNUSED_LET_DECLARATION = 'unusedLetDeclaration', + UNUSED_STANDALONE_IMPORTS = 'unusedStandaloneImports', } diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/api.ts b/packages/compiler-cli/src/ngtsc/metadata/src/api.ts index 6cbc25598b4fa..207e114ef282f 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/api.ts @@ -244,6 +244,11 @@ export interface DirectiveMeta extends T2DirectiveMeta, DirectiveTypeCheckMeta { */ imports: Reference[] | null; + /** + * Node declaring the `imports` of a standalone component. Used to produce diagnostics. + */ + rawImports: ts.Expression | null; + /** * For standalone components, the list of imported types that can be used * in `@defer` blocks (when only explicit dependencies are allowed). diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts b/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts index 19f1b0c038600..43a2d3b458fe6 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts @@ -181,6 +181,7 @@ export class DtsMetadataReader implements MetadataReader { // Imports are tracked in metadata only for template type-checking purposes, // so standalone components from .d.ts files don't have any. imports: null, + rawImports: null, deferredImports: null, // The same goes for schemas. schemas: null, diff --git a/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts b/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts index 6c44f7aa30389..68834e99f610a 100644 --- a/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts +++ b/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts @@ -346,6 +346,7 @@ function fakeDirective(ref: Reference): DirectiveMeta { isStandalone: false, isSignal: false, imports: null, + rawImports: null, schemas: null, decorator: null, hostDirectives: null, diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/api.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/api.ts index b0d6591765854..654db37a0c7ba 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/api.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/api.ts @@ -40,6 +40,8 @@ export interface TypeCheckableDirectiveMeta extends DirectiveMeta, DirectiveType hostDirectives: HostDirectiveMeta[] | null; decorator: ts.Decorator | null; isExplicitlyDeferred: boolean; + imports: Reference[] | null; + rawImports: ts.Expression | null; } export type TemplateId = string & {__brand: 'TemplateId'}; @@ -294,6 +296,11 @@ export interface TypeCheckingConfig { */ controlFlowPreventingContentProjection: 'error' | 'warning' | 'suppress'; + /** + * Whether to check if `@Component.imports` contains unused symbols. + */ + unusedStandaloneImports: 'error' | 'warning' | 'suppress'; + /** * Whether to use any generic types of the context component. * diff --git a/packages/compiler-cli/src/ngtsc/typecheck/extended/index.ts b/packages/compiler-cli/src/ngtsc/typecheck/extended/index.ts index c56e9d945aa04..ed7a9a5f79e3b 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/extended/index.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/index.ts @@ -40,5 +40,6 @@ export const ALL_DIAGNOSTIC_FACTORIES: readonly TemplateCheckFactory< export const SUPPORTED_DIAGNOSTIC_NAMES = new Set([ ExtendedTemplateDiagnosticName.CONTROL_FLOW_PREVENTING_CONTENT_PROJECTION, + ExtendedTemplateDiagnosticName.UNUSED_STANDALONE_IMPORTS, ...ALL_DIAGNOSTIC_FACTORIES.map((factory) => factory.name), ]); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts index 82fd517d0c64f..0b76073392991 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts @@ -963,6 +963,7 @@ describe('type check blocks', () => { useInlineTypeConstructors: true, suggestionsForSuboptimalTypeInference: false, controlFlowPreventingContentProjection: 'warning', + unusedStandaloneImports: 'warning', allowSignalsInTwoWayBindings: true, }; diff --git a/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts b/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts index b8ef092605670..e7ccdcdf6e921 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts @@ -282,6 +282,7 @@ export const ALL_ENABLED_CONFIG: Readonly = { useInlineTypeConstructors: true, suggestionsForSuboptimalTypeInference: false, controlFlowPreventingContentProjection: 'warning', + unusedStandaloneImports: 'warning', allowSignalsInTwoWayBindings: true, }; @@ -414,6 +415,7 @@ export function tcb( checkControlFlowBodies: true, alwaysCheckSchemaInTemplateBodies: true, controlFlowPreventingContentProjection: 'warning', + unusedStandaloneImports: 'warning', strictSafeNavigationTypes: true, useContextGenericType: true, strictLiteralTypes: true, @@ -893,6 +895,8 @@ function getDirectiveMetaFromDeclaration( ngContentSelectors: decl.ngContentSelectors || null, preserveWhitespaces: decl.preserveWhitespaces ?? false, isExplicitlyDeferred: false, + imports: decl.imports, + rawImports: null, hostDirectives: decl.hostDirectives === undefined ? null @@ -948,6 +952,7 @@ function makeScope(program: ts.Program, sf: ts.SourceFile, decls: TestDeclaratio isStandalone: false, isSignal: false, imports: null, + rawImports: null, deferredImports: null, schemas: null, decorator: null, diff --git a/packages/compiler-cli/src/ngtsc/validation/BUILD.bazel b/packages/compiler-cli/src/ngtsc/validation/BUILD.bazel index 7a201566e688a..fe862ea541a9e 100644 --- a/packages/compiler-cli/src/ngtsc/validation/BUILD.bazel +++ b/packages/compiler-cli/src/ngtsc/validation/BUILD.bazel @@ -12,6 +12,7 @@ ts_library( "//packages/compiler-cli/src/ngtsc/diagnostics", "//packages/compiler-cli/src/ngtsc/imports", "//packages/compiler-cli/src/ngtsc/reflection", + "//packages/compiler-cli/src/ngtsc/typecheck/api", "@npm//@types/node", "@npm//typescript", ], diff --git a/packages/compiler-cli/src/ngtsc/validation/src/config.ts b/packages/compiler-cli/src/ngtsc/validation/src/config.ts new file mode 100644 index 0000000000000..ba93a3a32f4b1 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/validation/src/config.ts @@ -0,0 +1,13 @@ +/*! + * @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.io/license + */ + +/** + * Whether the rule to check for unused standalone imports is enabled. + * Used to disable it conditionally in internal builds. + */ +export const UNUSED_STANDALONE_IMPORTS_RULE_ENABLED = true; diff --git a/packages/compiler-cli/src/ngtsc/validation/src/rules/unused_standalone_imports_rule.ts b/packages/compiler-cli/src/ngtsc/validation/src/rules/unused_standalone_imports_rule.ts new file mode 100644 index 0000000000000..45c32050f0c49 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/validation/src/rules/unused_standalone_imports_rule.ts @@ -0,0 +1,140 @@ +/*! + * @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.io/license + */ + +import ts from 'typescript'; + +import {ErrorCode, makeDiagnostic, makeRelatedInformation} from '../../../diagnostics'; +import {ImportedSymbolsTracker, Reference} from '../../../imports'; +import { + TemplateTypeChecker, + TypeCheckableDirectiveMeta, + TypeCheckingConfig, +} from '../../../typecheck/api'; + +import {SourceFileValidatorRule} from './api'; + +/** + * Rule that flags unused symbols inside of the `imports` array of a component. + */ +export class UnusedStandaloneImportsRule implements SourceFileValidatorRule { + constructor( + private templateTypeChecker: TemplateTypeChecker, + private typeCheckingConfig: TypeCheckingConfig, + private importedSymbolsTracker: ImportedSymbolsTracker, + ) {} + + shouldCheck(sourceFile: ts.SourceFile): boolean { + return ( + this.typeCheckingConfig.unusedStandaloneImports !== 'suppress' && + (this.importedSymbolsTracker.hasNamedImport(sourceFile, 'Component', '@angular/core') || + this.importedSymbolsTracker.hasNamespaceImport(sourceFile, '@angular/core')) + ); + } + + checkNode(node: ts.Node): ts.Diagnostic | null { + if (!ts.isClassDeclaration(node)) { + return null; + } + + const metadata = this.templateTypeChecker.getDirectiveMetadata(node); + + if ( + !metadata || + !metadata.isStandalone || + metadata.rawImports === null || + metadata.imports === null || + metadata.imports.length === 0 + ) { + return null; + } + + const usedDirectives = this.templateTypeChecker.getUsedDirectives(node); + const usedPipes = this.templateTypeChecker.getUsedPipes(node); + + // These will be null if the component is invalid for some reason. + if (!usedDirectives || !usedPipes) { + return null; + } + + const unused = this.getUnusedSymbols( + metadata, + new Set(usedDirectives.map((dir) => dir.ref.node as ts.ClassDeclaration)), + new Set(usedPipes), + ); + + if (unused === null) { + return null; + } + + const category = + this.typeCheckingConfig.unusedStandaloneImports === 'error' + ? ts.DiagnosticCategory.Error + : ts.DiagnosticCategory.Warning; + + if (unused.length === metadata.imports.length) { + return makeDiagnostic( + ErrorCode.UNUSED_STANDALONE_IMPORTS, + metadata.rawImports, + 'All imports are unused', + undefined, + category, + ); + } + + return makeDiagnostic( + ErrorCode.UNUSED_STANDALONE_IMPORTS, + metadata.rawImports, + 'Imports array contains unused imports', + unused.map(([ref, type, name]) => + makeRelatedInformation( + ref.getOriginForDiagnostics(metadata.rawImports!), + `${type} "${name}" is not used within the template`, + ), + ), + category, + ); + } + + private getUnusedSymbols( + metadata: TypeCheckableDirectiveMeta, + usedDirectives: Set, + usedPipes: Set, + ) { + if (metadata.imports === null || metadata.rawImports === null) { + return null; + } + + let unused: [ref: Reference, type: string, name: string][] | null = null; + + for (const current of metadata.imports) { + const currentNode = current.node as ts.ClassDeclaration; + const dirMeta = this.templateTypeChecker.getDirectiveMetadata(currentNode); + + if (dirMeta !== null) { + if (dirMeta.isStandalone && (usedDirectives === null || !usedDirectives.has(currentNode))) { + unused ??= []; + unused.push([current, dirMeta.isComponent ? 'Component' : 'Directive', dirMeta.name]); + } + continue; + } + + const pipeMeta = this.templateTypeChecker.getPipeMetadata(currentNode); + + if ( + pipeMeta !== null && + pipeMeta.isStandalone && + (usedPipes === null || !usedPipes.has(pipeMeta.name)) + ) { + unused ??= []; + unused.push([current, 'Pipe', pipeMeta.ref.node.name.text]); + } + } + + return unused; + } +} diff --git a/packages/compiler-cli/src/ngtsc/validation/src/source_file_validator.ts b/packages/compiler-cli/src/ngtsc/validation/src/source_file_validator.ts index 63133ca0174c2..229bb0fca1cc7 100644 --- a/packages/compiler-cli/src/ngtsc/validation/src/source_file_validator.ts +++ b/packages/compiler-cli/src/ngtsc/validation/src/source_file_validator.ts @@ -13,6 +13,9 @@ import {ReflectionHost} from '../../reflection'; import {SourceFileValidatorRule} from './rules/api'; import {InitializerApiUsageRule} from './rules/initializer_api_usage_rule'; +import {UnusedStandaloneImportsRule} from './rules/unused_standalone_imports_rule'; +import {TemplateTypeChecker, TypeCheckingConfig} from '../../typecheck/api'; +import {UNUSED_STANDALONE_IMPORTS_RULE_ENABLED} from './config'; /** * Validates that TypeScript files match a specific set of rules set by the Angular compiler. @@ -20,8 +23,23 @@ import {InitializerApiUsageRule} from './rules/initializer_api_usage_rule'; export class SourceFileValidator { private rules: SourceFileValidatorRule[]; - constructor(reflector: ReflectionHost, importedSymbolsTracker: ImportedSymbolsTracker) { + constructor( + reflector: ReflectionHost, + importedSymbolsTracker: ImportedSymbolsTracker, + templateTypeChecker: TemplateTypeChecker, + typeCheckingConfig: TypeCheckingConfig, + ) { this.rules = [new InitializerApiUsageRule(reflector, importedSymbolsTracker)]; + + if (UNUSED_STANDALONE_IMPORTS_RULE_ENABLED) { + this.rules.push( + new UnusedStandaloneImportsRule( + templateTypeChecker, + typeCheckingConfig, + importedSymbolsTracker, + ), + ); + } } /** diff --git a/packages/compiler-cli/test/ngtsc/standalone_spec.ts b/packages/compiler-cli/test/ngtsc/standalone_spec.ts index 0cdd48c9c1752..3ccb5d93cbe2e 100644 --- a/packages/compiler-cli/test/ngtsc/standalone_spec.ts +++ b/packages/compiler-cli/test/ngtsc/standalone_spec.ts @@ -1127,7 +1127,7 @@ runInEachFileSystem(() => { standalone: true, selector: 'standalone-cmp', imports: [DepCmp], - template: '', + template: '', }) export class StandaloneCmp {} diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts index 7d73f906eaaab..c349070852014 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -7438,5 +7438,307 @@ suppress ); }); }); + + describe('unused standalone imports', () => { + it('should report when a directive is not used within a template', () => { + env.write( + 'used.ts', + ` + import {Directive} from '@angular/core'; + + @Directive({selector: '[used]', standalone: true}) + export class UsedDir {} + `, + ); + + env.write( + 'unused.ts', + ` + import {Directive} from '@angular/core'; + + @Directive({selector: '[unused]', standalone: true}) + export class UnusedDir {} + `, + ); + + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + import {UsedDir} from './used'; + import {UnusedDir} from './unused'; + + @Component({ + template: \` +
+
+ +
+ \`, + standalone: true, + imports: [UsedDir, UnusedDir] + }) + export class MyComp {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toBe('Imports array contains unused imports'); + expect(diags[0].relatedInformation?.length).toBe(1); + expect(diags[0].relatedInformation![0].messageText).toBe( + 'Directive "UnusedDir" is not used within the template', + ); + }); + + it('should report when a pipe is not used within a template', () => { + env.write( + 'used.ts', + ` + import {Pipe} from '@angular/core'; + + @Pipe({name: 'used', standalone: true}) + export class UsedPipe { + transform(value: number) { + return value * 2; + } + } + `, + ); + + env.write( + 'unused.ts', + ` + import {Pipe} from '@angular/core'; + + @Pipe({name: 'unused', standalone: true}) + export class UnusedPipe { + transform(value: number) { + return value * 2; + } + } + `, + ); + + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + import {UsedPipe} from './used'; + import {UnusedPipe} from './unused'; + + @Component({ + template: \` +
+
+ +
+ \`, + standalone: true, + imports: [UsedPipe, UnusedPipe] + }) + export class MyComp {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toBe('Imports array contains unused imports'); + expect(diags[0].relatedInformation?.length).toBe(1); + expect(diags[0].relatedInformation?.[0].messageText).toBe( + 'Pipe "UnusedPipe" is not used within the template', + ); + }); + + it('should not report imports only used inside @defer blocks', () => { + env.write( + 'test.ts', + ` + import {Component, Directive, Pipe} from '@angular/core'; + + @Directive({selector: '[used]', standalone: true}) + export class UsedDir {} + + @Pipe({name: 'used', standalone: true}) + export class UsedPipe { + transform(value: number) { + return value * 2; + } + } + + @Component({ + template: \` +
+ @defer (on idle) { +
+ + } +
+ \`, + standalone: true, + imports: [UsedDir, UsedPipe] + }) + export class MyComp {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + + it('should report when all imports in an import array are not used', () => { + env.write( + 'test.ts', + ` + import {Component, Directive, Pipe} from '@angular/core'; + + @Directive({selector: '[unused]', standalone: true}) + export class UnusedDir {} + + @Pipe({name: 'unused', standalone: true}) + export class UnusedPipe { + transform(value: number) { + return value * 2; + } + } + + @Component({ + template: '', + standalone: true, + imports: [UnusedDir, UnusedPipe] + }) + export class MyComp {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toBe('All imports are unused'); + expect(diags[0].relatedInformation).toBeFalsy(); + }); + + it('should not report unused imports coming from modules', () => { + env.write( + 'module.ts', + ` + import {Directive, NgModule} from '@angular/core'; + + @Directive({selector: '[unused-from-module]'}) + export class UnusedDirFromModule {} + + @NgModule({ + declarations: [UnusedDirFromModule], + exports: [UnusedDirFromModule] + }) + export class UnusedModule {} + `, + ); + + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + import {UnusedModule} from './module'; + + @Component({ + template: '', + standalone: true, + imports: [UnusedModule] + }) + export class MyComp {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + + it('should be able to opt out for checking for unused imports via the tsconfig', () => { + env.tsconfig({ + extendedDiagnostics: { + checks: { + unusedStandaloneImports: DiagnosticCategoryLabel.Suppress, + }, + }, + }); + + env.write( + 'test.ts', + ` + import {Component, Directive} from '@angular/core'; + + @Directive({selector: '[unused]', standalone: true}) + export class UnusedDir {} + + @Component({ + template: '', + standalone: true, + imports: [UnusedDir] + }) + export class MyComp {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + + it('should unused imports from external modules', () => { + // Note: we don't use the existing fake `@angular/common`, + // because all the declarations there are non-standalone. + env.write( + 'node_modules/fake-common/index.d.ts', + ` + import * as i0 from '@angular/core'; + + export declare class NgIf { + static ɵdir: i0.ɵɵDirectiveDeclaration, "[ngIf]", never, {}, {}, never, never, true, never>; + static ɵfac: i0.ɵɵFactoryDeclaration, never>; + } + + export declare class NgFor { + static ɵdir: i0.ɵɵDirectiveDeclaration, "[ngFor]", never, {}, {}, never, never, true, never>; + static ɵfac: i0.ɵɵFactoryDeclaration, never>; + } + + export class PercentPipe { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵpipe: i0.ɵɵPipeDeclaration; + } + `, + ); + + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + import {NgIf, NgFor, PercentPipe} from 'fake-common'; + + @Component({ + template: \` +
+
+ +
+ \`, + standalone: true, + imports: [NgFor, NgIf, PercentPipe] + }) + export class MyComp {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toBe('Imports array contains unused imports'); + expect(diags[0].relatedInformation?.length).toBe(2); + expect(diags[0].relatedInformation![0].messageText).toBe( + 'Directive "NgFor" is not used within the template', + ); + expect(diags[0].relatedInformation![1].messageText).toBe( + 'Pipe "PercentPipe" is not used within the template', + ); + }); + }); }); }); From 8da9fb49b54e50de2d028691f73fb773def62ecd Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 3 Sep 2024 21:17:54 +0200 Subject: [PATCH 27/41] feat(language-service): add code fix for unused standalone imports (#57605) Adds an automatic code fix to the language service that will remove unused standalone imports. PR Close #57605 --- .../src/codefixes/all_codefixes_metas.ts | 2 + .../fix_unused_standalone_imports.ts | 76 +++++++++++++++ .../language-service/src/codefixes/utils.ts | 1 + .../language-service/test/code_fixes_spec.ts | 92 +++++++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 packages/language-service/src/codefixes/fix_unused_standalone_imports.ts diff --git a/packages/language-service/src/codefixes/all_codefixes_metas.ts b/packages/language-service/src/codefixes/all_codefixes_metas.ts index 0fee778cf6f77..a979d5d05c854 100644 --- a/packages/language-service/src/codefixes/all_codefixes_metas.ts +++ b/packages/language-service/src/codefixes/all_codefixes_metas.ts @@ -9,10 +9,12 @@ import {fixInvalidBananaInBoxMeta} from './fix_invalid_banana_in_box'; import {missingImportMeta} from './fix_missing_import'; import {missingMemberMeta} from './fix_missing_member'; +import {fixUnusedStandaloneImportsMeta} from './fix_unused_standalone_imports'; import {CodeActionMeta} from './utils'; export const ALL_CODE_FIXES_METAS: CodeActionMeta[] = [ missingMemberMeta, fixInvalidBananaInBoxMeta, missingImportMeta, + fixUnusedStandaloneImportsMeta, ]; diff --git a/packages/language-service/src/codefixes/fix_unused_standalone_imports.ts b/packages/language-service/src/codefixes/fix_unused_standalone_imports.ts new file mode 100644 index 0000000000000..83468cc312756 --- /dev/null +++ b/packages/language-service/src/codefixes/fix_unused_standalone_imports.ts @@ -0,0 +1,76 @@ +/** + * @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.io/license + */ + +import {ErrorCode, ngErrorCode} from '@angular/compiler-cli/src/ngtsc/diagnostics'; +import tss from 'typescript'; + +import {CodeActionMeta, FixIdForCodeFixesAll} from './utils'; +import {findFirstMatchingNode} from '../utils/ts_utils'; + +/** + * Fix for [unused standalone imports](https://angular.io/extended-diagnostics/NG8113) + */ +export const fixUnusedStandaloneImportsMeta: CodeActionMeta = { + errorCodes: [ngErrorCode(ErrorCode.UNUSED_STANDALONE_IMPORTS)], + getCodeActions: () => [], + fixIds: [FixIdForCodeFixesAll.FIX_UNUSED_STANDALONE_IMPORTS], + getAllCodeActions: ({diagnostics}) => { + const changes: tss.FileTextChanges[] = []; + + for (const diag of diagnostics) { + const {start, length, file, relatedInformation} = diag; + if (file === undefined || start === undefined || length == undefined) { + continue; + } + + const node = findFirstMatchingNode(file, { + filter: (current): current is tss.ArrayLiteralExpression => + current.getStart() === start && + current.getWidth() === length && + tss.isArrayLiteralExpression(current), + }); + + if (node === null) { + continue; + } + + let newText: string; + + // If `relatedInformation` is empty, it means that all the imports are unused. + // Replace the array with an empty array. + if (relatedInformation === undefined || relatedInformation.length === 0) { + newText = '[]'; + } else { + // Otherwise each `relatedInformation` entry points to an unused import that should be + // filtered out. We make a set of ranges corresponding to nodes which will be deleted and + // remove all nodes that belong to the set. + const excludeRanges = new Set( + relatedInformation.map((info) => `${info.start}-${info.length}`), + ); + const newArray = tss.factory.updateArrayLiteralExpression( + node, + node.elements.filter((el) => !excludeRanges.has(`${el.getStart()}-${el.getWidth()}`)), + ); + + newText = tss.createPrinter().printNode(tss.EmitHint.Unspecified, newArray, file); + } + + changes.push({ + fileName: file.fileName, + textChanges: [ + { + span: {start, length}, + newText, + }, + ], + }); + } + + return {changes}; + }, +}; diff --git a/packages/language-service/src/codefixes/utils.ts b/packages/language-service/src/codefixes/utils.ts index 2dfd2b0ffc83e..bea89964f3c27 100644 --- a/packages/language-service/src/codefixes/utils.ts +++ b/packages/language-service/src/codefixes/utils.ts @@ -137,4 +137,5 @@ export enum FixIdForCodeFixesAll { FIX_MISSING_MEMBER = 'fixMissingMember', FIX_INVALID_BANANA_IN_BOX = 'fixInvalidBananaInBox', FIX_MISSING_IMPORT = 'fixMissingImport', + FIX_UNUSED_STANDALONE_IMPORTS = 'fixUnusedStandaloneImports', } diff --git a/packages/language-service/test/code_fixes_spec.ts b/packages/language-service/test/code_fixes_spec.ts index 9302ef03499c0..3c102ff59d890 100644 --- a/packages/language-service/test/code_fixes_spec.ts +++ b/packages/language-service/test/code_fixes_spec.ts @@ -570,6 +570,98 @@ describe('code fixes', () => { ]); }); }); + + describe('unused standalone imports', () => { + it('should fix imports array where some imports are not used', () => { + const files = { + 'app.ts': ` + import {Component, Directive, Pipe} from '@angular/core'; + + @Directive({selector: '[used]', standalone: true}) + export class UsedDirective {} + + @Directive({selector: '[unused]', standalone: true}) + export class UnusedDirective {} + + @Pipe({name: 'unused', standalone: true}) + export class UnusedPipe {} + + @Component({ + selector: 'used-cmp', + standalone: true, + template: '', + }) + export class UsedComponent {} + + @Component({ + template: \` +
+
+ +
+ +
+
+ \`, + standalone: true, + imports: [UnusedDirective, UsedDirective, UnusedPipe, UsedComponent], + }) + export class AppComponent {} + `, + }; + + const project = createModuleAndProjectWithDeclarations(env, 'test', files); + const appFile = project.openFile('app.ts'); + + const fixesAllActions = project.getCombinedCodeFix( + 'app.ts', + FixIdForCodeFixesAll.FIX_UNUSED_STANDALONE_IMPORTS, + ); + expectIncludeReplacementTextForFileTextChange({ + fileTextChanges: fixesAllActions.changes, + content: appFile.contents, + text: '[UnusedDirective, UsedDirective, UnusedPipe, UsedComponent]', + newText: '[UsedDirective, UsedComponent]', + fileName: 'app.ts', + }); + }); + + it('should fix imports array where all imports are not used', () => { + const files = { + 'app.ts': ` + import {Component, Directive, Pipe} from '@angular/core'; + + @Directive({selector: '[unused]', standalone: true}) + export class UnusedDirective {} + + @Pipe({name: 'unused', standalone: true}) + export class UnusedPipe {} + + @Component({ + template: '', + standalone: true, + imports: [UnusedDirective, UnusedPipe], + }) + export class AppComponent {} + `, + }; + + const project = createModuleAndProjectWithDeclarations(env, 'test', files); + const appFile = project.openFile('app.ts'); + + const fixesAllActions = project.getCombinedCodeFix( + 'app.ts', + FixIdForCodeFixesAll.FIX_UNUSED_STANDALONE_IMPORTS, + ); + expectIncludeReplacementTextForFileTextChange({ + fileTextChanges: fixesAllActions.changes, + content: appFile.contents, + text: '[UnusedDirective, UnusedPipe]', + newText: '[]', + fileName: 'app.ts', + }); + }); + }); }); type ActionChanges = { From c19b02f6f42f3ca4c5a9f47b776bdfbc4f517046 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Wed, 4 Sep 2024 15:27:32 +0000 Subject: [PATCH 28/41] docs: release notes for the v18.2.3 release --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae61edc6458d4..4e27074f9b1d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ + +# 18.2.3 (2024-09-04) +### http +| Commit | Type | Description | +| -- | -- | -- | +| [de68e049e4](https://github.com/angular/angular/commit/de68e049e40ab702d9e2b7dd02070de9856377df) | fix | Dynamicaly call the global fetch implementation ([#57531](https://github.com/angular/angular/pull/57531)) | + + + # 19.0.0-next.2 (2024-08-28) ## Breaking Changes From 3c756848f7296144e2400c51c63a1fae321bd137 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Wed, 4 Sep 2024 15:29:23 +0000 Subject: [PATCH 29/41] release: cut the v19.0.0-next.3 release --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e27074f9b1d8..a5725b75d1f55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,35 @@ + +# 19.0.0-next.3 (2024-09-04) +## Breaking Changes +### core +- * TypeScript versions less than 5.5 are no longer supported. +### compiler +| Commit | Type | Description | +| -- | -- | -- | +| [a2e4ee0cb3](https://github.com/angular/angular/commit/a2e4ee0cb3d40cadc05e28d58b06853973944456) | feat | add diagnostic for unused standalone imports ([#57605](https://github.com/angular/angular/pull/57605)) | +### core +| Commit | Type | Description | +| -- | -- | -- | +| [8bcc663a53](https://github.com/angular/angular/commit/8bcc663a53888717cdf4ce0c23404caa00abb1b2) | feat | drop support for TypeScript 5.4 ([#57577](https://github.com/angular/angular/pull/57577)) | +| [e6e5d29e83](https://github.com/angular/angular/commit/e6e5d29e830a0a74d7677d5f2345f29391064853) | feat | initial version of the output migration ([#57604](https://github.com/angular/angular/pull/57604)) | +| [be2e49639b](https://github.com/angular/angular/commit/be2e49639bda831831ad62d49253db942a83fd46) | feat | introduce `afterRenderEffect` ([#57549](https://github.com/angular/angular/pull/57549)) | +### elements +| Commit | Type | Description | +| -- | -- | -- | +| [fe5c4e086a](https://github.com/angular/angular/commit/fe5c4e086add655bf53315d71b0736ff758c7199) | fix | support `output()`-shaped outputs ([#57535](https://github.com/angular/angular/pull/57535)) | +### http +| Commit | Type | Description | +| -- | -- | -- | +| [c2892fee58](https://github.com/angular/angular/commit/c2892fee58d28ffec0dfeaad6a5d6822c040cf03) | fix | Dynamicaly call the global fetch implementation ([#57531](https://github.com/angular/angular/pull/57531)) | +### language-service +| Commit | Type | Description | +| -- | -- | -- | +| [8da9fb49b5](https://github.com/angular/angular/commit/8da9fb49b54e50de2d028691f73fb773def62ecd) | feat | add code fix for unused standalone imports ([#57605](https://github.com/angular/angular/pull/57605)) | +| [1f067f4507](https://github.com/angular/angular/commit/1f067f4507b6e908fe991d5de0dc4d3a627ab2f9) | feat | add code reactoring action to migrate `@Input` to signal-input ([#57214](https://github.com/angular/angular/pull/57214)) | +| [56ee47f2ec](https://github.com/angular/angular/commit/56ee47f2ec6e983e2ffdf59476ab29a92590811e) | feat | allow code refactorings to compute edits asynchronously ([#57214](https://github.com/angular/angular/pull/57214)) | + + + # 18.2.3 (2024-09-04) ### http diff --git a/package.json b/package.json index 89488486e7e38..ed25911c083fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "angular-srcs", - "version": "19.0.0-next.2", + "version": "19.0.0-next.3", "private": true, "description": "Angular - a web framework for modern web apps", "homepage": "https://github.com/angular/angular", From e2d64947cd06fa272c8b1e112c3423d619ebd036 Mon Sep 17 00:00:00 2001 From: Arshjeet2003 Date: Sun, 25 Aug 2024 13:20:25 +0530 Subject: [PATCH 30/41] docs: Add fallback content for in content projection guide (#57513) Angular 18 introduced fallback content for . This commit updates the docs of content projection guide to contain fallback content for . PR Close angular#57083 PR Close #57513 --- .../guide/components/content-projection.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/adev/src/content/guide/components/content-projection.md b/adev/src/content/guide/components/content-projection.md index a91950897e12c..59895427bfb48 100644 --- a/adev/src/content/guide/components/content-projection.md +++ b/adev/src/content/guide/components/content-projection.md @@ -148,6 +148,38 @@ did not match a `select` attribute: If a component does not include an `` placeholder without a `select` attribute, any elements that don't match one of the component's placeholders do not render into the DOM. +## Fallback content + +Angular can show *fallback content* for a component's `` placeholder if that component doesn't have any matching child content. You can specify fallback content by adding child content to the `` element itself. + +```angular-html + +
+ Default Title +
+ Default Body +
+``` + +```angular-html + + + Hello + + +``` + +```angular-html + + +
+ Hello +
+ Default Body +
+
+``` + ## Aliasing content for projection Angular supports a special attribute, `ngProjectAs`, that allows you to specify a CSS selector on From 69c2ef7a2d896af5d7d3b31f3964b569cc816578 Mon Sep 17 00:00:00 2001 From: Angular Robot Date: Tue, 3 Sep 2024 23:03:45 +0000 Subject: [PATCH 31/41] build: update io_bazel_rules_sass digest to 5a7e3f4 (#57649) See associated pull request for more information. PR Close #57649 --- WORKSPACE | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WORKSPACE b/WORKSPACE index ee4d0d50b6f04..2cd61da95dce2 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -143,10 +143,10 @@ cldr_xml_data_repository( # sass rules http_archive( name = "io_bazel_rules_sass", - sha256 = "cd83736ea65d0df064283aea5922dbaf132dd2b3aa54e7151aae7edaa9572c3e", - strip_prefix = "rules_sass-83022b98114c07e9588089c7fe8f76bc0262c7e7", + sha256 = "99c26eb38ef3e4e63253796dec37030834b1db25d12f1aeed1481f7020eb95b3", + strip_prefix = "rules_sass-5a7e3f4e1ed8def01b2a1e52625b09f11e98f1f8", urls = [ - "https://github.com/bazelbuild/rules_sass/archive/83022b98114c07e9588089c7fe8f76bc0262c7e7.zip", + "https://github.com/bazelbuild/rules_sass/archive/5a7e3f4e1ed8def01b2a1e52625b09f11e98f1f8.zip", ], ) From 89d7351f81d5445d899ba933b58b9229906e5695 Mon Sep 17 00:00:00 2001 From: twerske Date: Fri, 23 Aug 2024 11:02:31 -0700 Subject: [PATCH 32/41] docs: update top level banner styles (#57503) PR Close #57503 --- .../top-level-banner.component.html | 6 +- .../top-level-banner.component.scss | 120 +++++++++++++----- adev/src/app/app.component.html | 2 +- 3 files changed, 94 insertions(+), 34 deletions(-) diff --git a/adev/shared-docs/components/top-level-banner/top-level-banner.component.html b/adev/shared-docs/components/top-level-banner/top-level-banner.component.html index 68e00ae15a992..3659e9f97eb35 100644 --- a/adev/shared-docs/components/top-level-banner/top-level-banner.component.html +++ b/adev/shared-docs/components/top-level-banner/top-level-banner.component.html @@ -1,11 +1,13 @@ @if (!hasClosed()) { @if (link()) { -

{{ text() }}

+

{{ text() }}

+

{{ text() }}

} @else {
-

{{ text() }}

+

{{ text() }}

+

{{ text() }}

} diff --git a/adev/shared-docs/components/top-level-banner/top-level-banner.component.scss b/adev/shared-docs/components/top-level-banner/top-level-banner.component.scss index 922b1d8d2e53b..88657a0c82b7e 100644 --- a/adev/shared-docs/components/top-level-banner/top-level-banner.component.scss +++ b/adev/shared-docs/components/top-level-banner/top-level-banner.component.scss @@ -18,40 +18,42 @@ h1.docs-top-level-banner-cta { display: inline; - position: relative; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); font-size: 0.875rem; margin: 0; - background-image: var(--red-to-pink-to-purple-horizontal-gradient); - background-clip: text; - -webkit-background-clip: text; - color: transparent; width: fit-content; font-weight: 500; - &::after { - content: ''; - position: absolute; - width: 100%; - transform: scaleX(0); - height: 1px; - bottom: -2px; - left: 0; - background: var(--tertiary-contrast); - animation-name: shimmer; - -webkit-animation-duration: 5s; - -moz-animation-duration: 5s; - animation-duration: 5s; - -webkit-animation-iteration-count: infinite; - -moz-animation-iteration-count: infinite; - animation-iteration-count: infinite; + &.background { + color: var(--tertiary-contrast); } - } - &:hover { - h1.docs-top-level-banner-cta { + &:not(.background) { + color: transparent; + &::after { - transform: scaleX(1); - transform-origin: bottom left; + content: ''; + position: absolute; + width: 100%; + height: 1px; + bottom: -2px; + left: 0; + background: var(--tertiary-contrast); + transform: scaleX(0); + transform-origin: bottom right; + @media (prefers-reduced-motion: no-preference) { + transition: transform 0.3s ease; + } + } + + &:hover { + &::after { + transform: scaleX(1); + transform-origin: bottom left; + } } } } @@ -60,17 +62,73 @@ position: absolute; top: 0.25rem; right: 0.5rem; - color: var(--primary-contrast); + color: var(--tertiary-contrast); + } +} + +.shimmer { + background: var(--red-to-pink-to-purple-horizontal-gradient); + + @media (prefers-reduced-motion: no-preference) { + background-repeat: no-repeat; + -webkit-background-size: 125px 100%; + -moz-background-size: 125px 100%; + background-size: 125px 100%; + -webkit-background-clip: text; + -moz-background-clip: text; + background-clip: text; + -webkit-animation-name: shimmer; + -moz-animation-name: shimmer; + animation-name: shimmer; + -webkit-animation-duration: 10s; + -moz-animation-duration: 10s; + animation-duration: 10s; + -webkit-animation-iteration-count: infinite; + -moz-animation-iteration-count: infinite; + animation-iteration-count: infinite; + } +} + +@-moz-keyframes shimmer { + 0% { + background-position: top left; + background-position-x: -150px; + } + 100% { + background-position: top right; + background-position-x: 500px; + } +} + +@-webkit-keyframes shimmer { + 0% { + background-position: top left; + background-position-x: -150px; + } + 100% { + background-position: top right; + background-position-x: 500px; + } +} + +@-o-keyframes shimmer { + 0% { + background-position: top left; + background-position-x: -150px; + } + 100% { + background-position: top right; + background-position-x: 500px; } } @keyframes shimmer { 0% { - transform: scaleX(0); - transform-origin: bottom right; + background-position: top left; + background-position-x: -150px; } 100% { - transform: scaleX(1); - transform-origin: bottom left; + background-position: top right; + background-position-x: 500px; } } diff --git a/adev/src/app/app.component.html b/adev/src/app/app.component.html index 119152d5901e9..5fb1d688a3f1e 100644 --- a/adev/src/app/app.component.html +++ b/adev/src/app/app.component.html @@ -1,6 +1,6 @@ @defer (when isBrowser) { - + } From 71f5ef2aa53a74bab7d0543f98870d81c44c4978 Mon Sep 17 00:00:00 2001 From: Pawel Kozlowski Date: Wed, 4 Sep 2024 12:11:05 +0200 Subject: [PATCH 33/41] fix(migrations): change imports to be G3 compatible (#57654) A set of fixes to the import paths - the goal is to make the output migration compatible with the G3 infrastructure. PR Close #57654 --- packages/compiler-cli/private/migrations.ts | 1 + .../migrations/output-migration/output-replacements.ts | 9 ++++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/compiler-cli/private/migrations.ts b/packages/compiler-cli/private/migrations.ts index 9af16069a8742..0b26eeb663f2f 100644 --- a/packages/compiler-cli/private/migrations.ts +++ b/packages/compiler-cli/private/migrations.ts @@ -12,6 +12,7 @@ */ export {forwardRefResolver} from '../src/ngtsc/annotations'; +export {AbsoluteFsPath} from '../src/ngtsc/file_system'; export {Reference} from '../src/ngtsc/imports'; export { DynamicValue, diff --git a/packages/core/schematics/migrations/output-migration/output-replacements.ts b/packages/core/schematics/migrations/output-migration/output-replacements.ts index 2d8a94ddf7896..ab750d0dc519b 100644 --- a/packages/core/schematics/migrations/output-migration/output-replacements.ts +++ b/packages/core/schematics/migrations/output-migration/output-replacements.ts @@ -7,15 +7,15 @@ */ import ts from 'typescript'; + +import {AbsoluteFsPath, ImportManager} from '../../../../compiler-cli/private/migrations'; import { - Replacement, - TextUpdate, ProjectRelativePath, projectRelativePath, + Replacement, + TextUpdate, } from '../../utils/tsurge'; -import {absoluteFromSourceFile, AbsoluteFsPath} from '@angular/compiler-cli'; import {applyImportManagerChanges} from '../../utils/tsurge/helpers/apply_import_manager'; -import {ImportManager} from '../../../../compiler-cli/private/migrations'; const printer = ts.createPrinter(); @@ -80,7 +80,6 @@ export function calculateImportReplacements( const addOnly: Replacement[] = []; const addRemove: Replacement[] = []; - const absolutePath = absoluteFromSourceFile(sf); importManager.addImport({ requestedFile: sf, exportModuleSpecifier: '@angular/core', From 9da21f798de2033af9d39a8a9b255ef2fe74f948 Mon Sep 17 00:00:00 2001 From: Pawel Kozlowski Date: Wed, 4 Sep 2024 13:53:20 +0200 Subject: [PATCH 34/41] feat(migrations): replace .next usage on outputs (#57654) This commits extends the decorator-based output migration to replace .next usage (not supported with the output function) with .emit (supported in both decorator-based and function based outputs). PR Close #57654 --- .../output-migration/output-migration.spec.ts | 57 ++++++++++--- .../output-migration/output-migration.ts | 85 +++++++++++++------ .../output-migration/output-replacements.ts | 15 ++++ .../output-migration/output_helpers.ts | 45 +++++++++- 4 files changed, 163 insertions(+), 39 deletions(-) diff --git a/packages/core/schematics/migrations/output-migration/output-migration.spec.ts b/packages/core/schematics/migrations/output-migration/output-migration.spec.ts index 274535805a890..9849cb67c2be2 100644 --- a/packages/core/schematics/migrations/output-migration/output-migration.spec.ts +++ b/packages/core/schematics/migrations/output-migration/output-migration.spec.ts @@ -106,32 +106,69 @@ describe('outputs', () => { }); }); - describe('declarations _with_ problematic access patterns', () => { - it('should _not_ migrate outputs that are used with .pipe', () => { - verifyNoChange(` + describe('.next migration', () => { + it('should migrate .next usages that should have been .emit', () => { + verify({ + before: ` import {Directive, Output, EventEmitter} from '@angular/core'; @Directive() export class TestDir { - @Output() someChange = new EventEmitter(); + @Output() someChange = new EventEmitter(); - someMethod() { - this.someChange.pipe(); + onClick() { + this.someChange.next('clicked'); } } - `); + `, + after: ` + import { Directive, output } from '@angular/core'; + + @Directive() + export class TestDir { + readonly someChange = output(); + + onClick() { + this.someChange.emit('clicked'); + } + } + `, + }); }); - it('should _not_ migrate outputs that are used with .next', () => { - verifyNoChange(` + it('should _not_ migrate .next usages when problematic output usage is detected', () => { + verifyNoChange( + ` import {Directive, Output, EventEmitter} from '@angular/core'; @Directive() export class TestDir { @Output() someChange = new EventEmitter(); + onClick() { + this.someChange.next('clicked'); + } + + ngOnDestroy() { + this.someChange.complete(); + } + } + `, + ); + }); + }); + + describe('declarations _with_ problematic access patterns', () => { + it('should _not_ migrate outputs that are used with .pipe', () => { + verifyNoChange(` + import {Directive, Output, EventEmitter} from '@angular/core'; + + @Directive() + export class TestDir { + @Output() someChange = new EventEmitter(); + someMethod() { - this.someChange.next('payload'); + this.someChange.pipe(); } } `); diff --git a/packages/core/schematics/migrations/output-migration/output-migration.ts b/packages/core/schematics/migrations/output-migration/output-migration.ts index 420ef96a1f1e2..0f7c6d5d784b9 100644 --- a/packages/core/schematics/migrations/output-migration/output-migration.ts +++ b/packages/core/schematics/migrations/output-migration/output-migration.ts @@ -20,14 +20,18 @@ import { import {DtsMetadataReader} from '../../../../compiler-cli/src/ngtsc/metadata'; import {TypeScriptReflectionHost} from '../../../../compiler-cli/src/ngtsc/reflection'; import { - isOutputDeclaration, OutputID, getUniqueIdForProperty, - getTargetPropertyDeclaration, + isTargetOutputDeclaration, extractSourceOutputDefinition, - isProblematicEventEmitterUsage, + isPotentialProblematicEventEmitterUsage, + isPotentialNextCallUsage, } from './output_helpers'; -import {calculateImportReplacements, calculateDeclarationReplacements} from './output-replacements'; +import { + calculateImportReplacements, + calculateDeclarationReplacements, + calculateNextFnReplacement, +} from './output-replacements'; interface OutputMigrationData { path: ProjectRelativePath; @@ -52,7 +56,7 @@ export class OutputMigration extends TsurgeFunnelMigration< program, projectDirAbsPath, }: ProgramInfo): Promise> { - const outputFields: Record = {}; + const outputFieldReplacements: Record = {}; const problematicUsages: Record = {}; const filesWithOutputDeclarations = new Set(); @@ -69,29 +73,43 @@ export class OutputMigration extends TsurgeFunnelMigration< const relativePath = projectRelativePath(node.getSourceFile(), projectDirAbsPath); filesWithOutputDeclarations.add(relativePath); - outputFields[outputDef.id] = { - path: relativePath, - replacements: calculateDeclarationReplacements( - projectDirAbsPath, - node, - outputDef.aliasParam, - ), - }; + addOutputReplacements( + outputFieldReplacements, + outputDef.id, + relativePath, + calculateDeclarationReplacements(projectDirAbsPath, node, outputDef.aliasParam), + ); + } + } + + // detect .next usages that should be migrated to .emit + if (isPotentialNextCallUsage(node) && ts.isPropertyAccessExpression(node.expression)) { + const propertyDeclaration = isTargetOutputDeclaration( + node.expression.expression, + checker, + reflector, + dtsReader, + ); + if (propertyDeclaration !== null) { + const id = getUniqueIdForProperty(projectDirAbsPath, propertyDeclaration); + const relativePath = projectRelativePath(node.getSourceFile(), projectDirAbsPath); + addOutputReplacements(outputFieldReplacements, id, relativePath, [ + calculateNextFnReplacement(projectDirAbsPath, node.expression.name), + ]); } } // detect unsafe access of the output property - if (isProblematicEventEmitterUsage(node)) { - const targetSymbol = checker.getSymbolAtLocation(node.expression); - if (targetSymbol !== undefined) { - const propertyDeclaration = getTargetPropertyDeclaration(targetSymbol); - if ( - propertyDeclaration !== null && - isOutputDeclaration(propertyDeclaration, reflector, dtsReader) - ) { - const id = getUniqueIdForProperty(projectDirAbsPath, propertyDeclaration); - problematicUsages[id] = true; - } + if (isPotentialProblematicEventEmitterUsage(node)) { + const propertyDeclaration = isTargetOutputDeclaration( + node.expression, + checker, + reflector, + dtsReader, + ); + if (propertyDeclaration !== null) { + const id = getUniqueIdForProperty(projectDirAbsPath, propertyDeclaration); + problematicUsages[id] = true; } } @@ -112,7 +130,7 @@ export class OutputMigration extends TsurgeFunnelMigration< ); return confirmAsSerializable({ - outputFields, + outputFields: outputFieldReplacements, importReplacements, problematicUsages, }); @@ -183,3 +201,20 @@ export class OutputMigration extends TsurgeFunnelMigration< return replacements; } } + +function addOutputReplacements( + outputFieldReplacements: Record, + outputId: OutputID, + relativePath: ProjectRelativePath, + replacements: Replacement[], +): void { + const existingReplacements = outputFieldReplacements[outputId]; + if (existingReplacements !== undefined) { + existingReplacements.replacements.push(...replacements); + } else { + outputFieldReplacements[outputId] = { + path: relativePath, + replacements: replacements, + }; + } +} diff --git a/packages/core/schematics/migrations/output-migration/output-replacements.ts b/packages/core/schematics/migrations/output-migration/output-replacements.ts index ab750d0dc519b..fc41e6dbcaf15 100644 --- a/packages/core/schematics/migrations/output-migration/output-replacements.ts +++ b/packages/core/schematics/migrations/output-migration/output-replacements.ts @@ -99,3 +99,18 @@ export function calculateImportReplacements( return importReplacements; } + +export function calculateNextFnReplacement( + projectDirAbsPath: AbsoluteFsPath, + node: ts.MemberName, +): Replacement { + const sf = node.getSourceFile(); + return new Replacement( + projectRelativePath(sf, projectDirAbsPath), + new TextUpdate({ + position: node.getStart(), + end: node.getEnd(), + toInsert: 'emit', + }), + ); +} diff --git a/packages/core/schematics/migrations/output-migration/output_helpers.ts b/packages/core/schematics/migrations/output-migration/output_helpers.ts index fe0917c5fd4fd..c570c5c403bf1 100644 --- a/packages/core/schematics/migrations/output-migration/output_helpers.ts +++ b/packages/core/schematics/migrations/output-migration/output_helpers.ts @@ -28,6 +28,8 @@ export interface ExtractedOutput { aliasParam?: ts.Expression; } +const PROBLEMATIC_OUTPUT_USAGES = new Set(['complete', 'pipe']); + /** * Determines if the given node refers to a decorator-based output, and * returns its resolved metadata if possible. @@ -58,17 +60,52 @@ function isOutputDeclarationEligibleForMigration(node: ts.PropertyDeclaration) { ); } -const problematicEventEmitterUsages = new Set(['pipe', 'next', 'complete']); -export function isProblematicEventEmitterUsage(node: ts.Node): node is ts.PropertyAccessExpression { +export function isPotentialProblematicEventEmitterUsage( + node: ts.Node, +): node is ts.PropertyAccessExpression { return ( ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name) && - problematicEventEmitterUsages.has(node.name.text) + PROBLEMATIC_OUTPUT_USAGES.has(node.name.text) ); } +export function isPotentialNextCallUsage(node: ts.Node): node is ts.CallExpression { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.name) + ) { + const methodName = node.expression.name.text; + if (methodName === 'next') { + return true; + } + } + + return false; +} + +export function isTargetOutputDeclaration( + node: ts.Node, + checker: ts.TypeChecker, + reflector: ReflectionHost, + dtsReader: DtsMetadataReader, +): ts.PropertyDeclaration | null { + const targetSymbol = checker.getSymbolAtLocation(node); + if (targetSymbol !== undefined) { + const propertyDeclaration = getTargetPropertyDeclaration(targetSymbol); + if ( + propertyDeclaration !== null && + isOutputDeclaration(propertyDeclaration, reflector, dtsReader) + ) { + return propertyDeclaration; + } + } + return null; +} + /** Gets whether the given property is an Angular `@Output`. */ -export function isOutputDeclaration( +function isOutputDeclaration( node: ts.PropertyDeclaration, reflector: ReflectionHost, dtsReader: DtsMetadataReader, From d8338b5f87571cc2b71767f88fa296f9a1176893 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Wed, 4 Sep 2024 09:51:51 +0000 Subject: [PATCH 35/41] refactor(migrations): generate blocks to support narrowing inside arrow function (#57659) Whenever the signal migration discovers multiple references in the same control flow container, it assumes narrowing and wants to preserve this functionality. It does this by introducing temporary variables. This works fine, but currently there is an edge case with arrow functions, as those can also turn into blocks, but aren't considered as such in the current code. This commit fixes this, so that arrow functions will be converted to be block-based if necessary. PR Close #57659 --- .../src/flow_analysis/index.ts | 21 ++++-- .../create_block_arrow_function.ts | 65 +++++++++++++++++++ .../object_expansion_refs.ts | 28 +------- .../standard_reference.ts | 45 ++++++++----- .../test/golden-test/narrowing.ts | 10 +++ .../signal-migration/test/golden.txt | 18 ++++- .../test/golden_best_effort.txt | 18 ++++- 7 files changed, 152 insertions(+), 53 deletions(-) create mode 100644 packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/create_block_arrow_function.ts diff --git a/packages/core/schematics/migrations/signal-migration/src/flow_analysis/index.ts b/packages/core/schematics/migrations/signal-migration/src/flow_analysis/index.ts index 1d98db7e0c280..38c4b8d3ef58d 100644 --- a/packages/core/schematics/migrations/signal-migration/src/flow_analysis/index.ts +++ b/packages/core/schematics/migrations/signal-migration/src/flow_analysis/index.ts @@ -35,10 +35,14 @@ export interface ControlFlowAnalysisNode { * Recommended node for the reference in the container. For example: * - may be "preserve" to indicate it's not narrowed. * - may point to a different flow node. This means they will share for narrowing. - * - may point to a block or source file to indicate at what level this node may be shared. - * I.e. a location where we generate the temporary variable for subsequent sharing. + * - may point to a block, source file or arrow function to indicate at what level this + * node may be shared. I.e. a location where we generate the temporary variable + * for subsequent sharing. */ - recommendedNode: ControlFlowNodeIndex | 'preserve' | (ts.Block | ts.SourceFile); + recommendedNode: + | ControlFlowNodeIndex + | 'preserve' + | (ts.Block | ts.SourceFile | ts.ArrowFunction); /** Flow container this reference is part of. */ flowContainer: ts.Node; } @@ -154,7 +158,7 @@ function connectSharedReferences( // the reference and the earliest partner. References in between can also // use the shared flow node and not preserve their original reference— as // this would be rather unreadable and inefficient. - let highestBlock: ts.Block | ts.SourceFile | null = null; + let highestBlock: ts.Block | ts.SourceFile | ts.ArrowFunction | null = null; for (let i = earliestPartnerId; i <= refId; i++) { // Different flow container captured sequentially in result. Ignore. if (result[i].flowContainer !== refFlowContainer) { @@ -163,7 +167,7 @@ function connectSharedReferences( // Iterate up the block, find the highest block within the flow container. let block: ts.Node = result[i].originalNode.parent; - while (!ts.isSourceFile(block) && !ts.isBlock(block)) { + while (!isBlockLikeAncestor(block)) { block = block.parent; } if (highestBlock === null || block.getStart() < highestBlock.getStart()) { @@ -179,6 +183,13 @@ function connectSharedReferences( result[earliestPartnerId].recommendedNode = highestBlock; } +function isBlockLikeAncestor(node: ts.Node): node is ts.ArrowFunction | ts.Block | ts.SourceFile { + // Note: Arrow functions may not have a block, but instead use an expression + // directly. This still signifies a "block" as we can convert the concise body + // to a block. + return ts.isSourceFile(node) || ts.isBlock(node) || ts.isArrowFunction(node); +} + /** * Looks through the flow path and interesting nodes to determine which * of the potential "interesting" nodes point to the same reference. diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/create_block_arrow_function.ts b/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/create_block_arrow_function.ts new file mode 100644 index 0000000000000..6c384331d225c --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/create_block_arrow_function.ts @@ -0,0 +1,65 @@ +/** + * @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.io/license + */ + +import ts from 'typescript'; +import {ProjectRelativePath, Replacement, TextUpdate} from '../../../../../utils/tsurge'; + +/** + * Creates replacements to insert the given statement as + * first statement into the arrow function. + * + * The arrow function is converted to a block-based arrow function + * that can hold multiple statements. The original expression is + * simply returned like before. + */ +export function createNewBlockToInsertVariable( + node: ts.ArrowFunction, + filePath: ProjectRelativePath, + toInsert: string, +): Replacement[] { + const sf = node.getSourceFile(); + + // For indentation, we traverse up and find the earliest statement. + // This node is most of the time a good candidate for acceptable + // indentation of a new block. + const spacingNode = ts.findAncestor(node, ts.isStatement) ?? node.parent; + const {character} = ts.getLineAndCharacterOfPosition(sf, spacingNode.getStart()); + const blockSpace = ' '.repeat(character); + const contentSpace = ' '.repeat(character + 2); + + return [ + // Delete leading whitespace of the concise body. + new Replacement( + filePath, + new TextUpdate({ + position: node.body.getFullStart(), + end: node.body.getStart(), + toInsert: '', + }), + ), + // Insert leading block braces, and `toInsert` content. + // Wrap the previous expression in a return now. + new Replacement( + filePath, + new TextUpdate({ + position: node.body.getStart(), + end: node.body.getStart(), + toInsert: ` {\n${contentSpace}${toInsert}\n${contentSpace}return `, + }), + ), + // Add trailing brace. + new Replacement( + filePath, + new TextUpdate({ + position: node.body.getEnd(), + end: node.body.getEnd(), + toInsert: `;\n${blockSpace}}`, + }), + ), + ]; +} diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/object_expansion_refs.ts b/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/object_expansion_refs.ts index d884665d3a9cb..1a6df5c07724a 100644 --- a/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/object_expansion_refs.ts +++ b/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/object_expansion_refs.ts @@ -18,6 +18,7 @@ import {AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; import {UniqueNamesGenerator} from '../../utils/unique_names'; import assert from 'assert'; import {MigrationResult} from '../../result'; +import {createNewBlockToInsertVariable} from './create_block_arrow_function'; /** An identifier part of a binding element. */ export interface IdentifierOfBindingElement extends ts.Identifier { @@ -163,32 +164,7 @@ function insertTemporaryVariableForBindingElement( // Other cases where we see an arrow function without a block. // We need to create one now. if (ts.isArrowFunction(parent) && !ts.isBlock(parent.body)) { - // For indentation, we traverse up and find the earliest statement. - // This node is most of the time a good candidate for acceptable - // indentation of a new block. - const spacingNode = ts.findAncestor(parent, ts.isStatement) ?? parent.parent; - const {character} = ts.getLineAndCharacterOfPosition(sf, spacingNode.getStart()); - const blockSpace = ' '.repeat(character); - const contentSpace = ' '.repeat(character + 2); - - return [ - new Replacement( - filePath, - new TextUpdate({ - position: parent.body.getStart(), - end: parent.body.getEnd(), - toInsert: `{\n${contentSpace}${toInsert}\n${contentSpace}return ${parent.body.getText()};`, - }), - ), - new Replacement( - filePath, - new TextUpdate({ - position: parent.body.getEnd(), - end: parent.body.getEnd(), - toInsert: `\n${blockSpace}}`, - }), - ), - ]; + return createNewBlockToInsertVariable(parent, filePath, toInsert); } return null; diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/standard_reference.ts b/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/standard_reference.ts index 81dd78795dc1e..a9a07f7937920 100644 --- a/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/standard_reference.ts +++ b/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/standard_reference.ts @@ -14,6 +14,7 @@ import {projectRelativePath, Replacement, TextUpdate} from '../../../../../utils import {AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; import {traverseAccess} from '../../utils/traverse_access'; import {UniqueNamesGenerator} from '../../utils/unique_names'; +import {createNewBlockToInsertVariable} from './create_block_arrow_function'; export interface NarrowableTsReference { accesses: ts.Identifier[]; @@ -77,32 +78,40 @@ export function migrateStandardTsReference( // to insert right before the first reference in the container, at the proper // block level— instead of always inserting at the beginning of the container. let parent = originalNode.parent; - let previous: ts.Node = originalNode; + let referenceNodeInBlock: ts.Node = originalNode; while (parent !== recommendedNode) { - previous = parent; + referenceNodeInBlock = parent; parent = parent.parent; } - if (ts.isArrowFunction(recommendedNode)) { - } - - const leadingSpace = ts.getLineAndCharacterOfPosition(sf, previous.getStart()); - const replaceNode = traverseAccess(originalNode); - const fieldName = nameGenerator.generate(originalNode.text, previous); + const fieldName = nameGenerator.generate(originalNode.text, referenceNodeInBlock); + const filePath = projectRelativePath(sf, projectDirAbsPath); + const temporaryVariableStr = `const ${fieldName} = ${replaceNode.getText()}();`; idToSharedField.set(id, fieldName); - result.replacements.push( - new Replacement( - projectRelativePath(sf, projectDirAbsPath), - new TextUpdate({ - position: previous.getStart(), - end: previous.getStart(), - toInsert: `const ${fieldName} = ${replaceNode.getText()}();\n${' '.repeat(leadingSpace.character)}`, - }), - ), - ); + // If the common ancestor block of all shared references is an arrow function + // without a block, convert the arrow function to a block and insert the temporary + // variable at the beginning. + if (ts.isArrowFunction(parent) && !ts.isBlock(parent.body)) { + result.replacements.push( + ...createNewBlockToInsertVariable(parent, filePath, temporaryVariableStr), + ); + } else { + const leadingSpace = ts.getLineAndCharacterOfPosition(sf, referenceNodeInBlock.getStart()); + + result.replacements.push( + new Replacement( + filePath, + new TextUpdate({ + position: referenceNodeInBlock.getStart(), + end: referenceNodeInBlock.getStart(), + toInsert: `${temporaryVariableStr}\n${' '.repeat(leadingSpace.character)}`, + }), + ), + ); + } result.replacements.push( new Replacement( diff --git a/packages/core/schematics/migrations/signal-migration/test/golden-test/narrowing.ts b/packages/core/schematics/migrations/signal-migration/test/golden-test/narrowing.ts index adc60c53a177d..e709046e5b088 100644 --- a/packages/core/schematics/migrations/signal-migration/test/golden-test/narrowing.ts +++ b/packages/core/schematics/migrations/signal-migration/test/golden-test/narrowing.ts @@ -10,6 +10,16 @@ export class Narrowing { [this].map((x) => x.name && x.name.charAt(0)); } + narrowingArrowFnMultiLineWrapped() { + [this].map( + (x) => + x.name && + x.name.includes( + 'A super long string to ensure this is wrapped and we can test formatting.', + ), + ); + } + narrowingObjectExpansion() { [this].map(({name}) => name && name.charAt(0)); } diff --git a/packages/core/schematics/migrations/signal-migration/test/golden.txt b/packages/core/schematics/migrations/signal-migration/test/golden.txt index 0caca04bd34c8..a0a6c228aaa6d 100644 --- a/packages/core/schematics/migrations/signal-migration/test/golden.txt +++ b/packages/core/schematics/migrations/signal-migration/test/golden.txt @@ -705,8 +705,22 @@ export class Narrowing { readonly name = input(); narrowingArrowFn() { - const name = x.name(); - [this].map((x) => name && name.charAt(0)); + [this].map((x) => { + const name = x.name(); + return name && name.charAt(0); + }); + } + + narrowingArrowFnMultiLineWrapped() { + [this].map( + (x) => { + const name = x.name(); + return name && + name.includes( + 'A super long string to ensure this is wrapped and we can test formatting.', + ); + }, + ); } narrowingObjectExpansion() { diff --git a/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt b/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt index 81beb612aa0d0..a071550574815 100644 --- a/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt +++ b/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt @@ -705,8 +705,22 @@ export class Narrowing { readonly name = input(); narrowingArrowFn() { - const name = x.name(); - [this].map((x) => name && name.charAt(0)); + [this].map((x) => { + const name = x.name(); + return name && name.charAt(0); + }); + } + + narrowingArrowFnMultiLineWrapped() { + [this].map( + (x) => { + const name = x.name(); + return name && + name.includes( + 'A super long string to ensure this is wrapped and we can test formatting.', + ); + }, + ); } narrowingObjectExpansion() { From 9c31ba95e5e48190f35ad1891fa51bc29b114af9 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Wed, 4 Sep 2024 13:11:07 +0000 Subject: [PATCH 36/41] refactor(migrations): properly apply edits in signal input refactoring action (#57659) The language service expects absolute paths, but Tsurge only deals with project relative paths. This commit fixes this. PR Close #57659 --- packages/language-service/src/refactorings/BUILD.bazel | 1 + .../src/refactorings/convert_to_signal_input.ts | 10 ++++++---- .../test/signal_input_refactoring_action_spec.ts | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/language-service/src/refactorings/BUILD.bazel b/packages/language-service/src/refactorings/BUILD.bazel index 290ca51e07bd2..66088774a5a0b 100644 --- a/packages/language-service/src/refactorings/BUILD.bazel +++ b/packages/language-service/src/refactorings/BUILD.bazel @@ -10,6 +10,7 @@ ts_library( deps = [ "//packages/compiler-cli", "//packages/compiler-cli/src/ngtsc/core", + "//packages/compiler-cli/src/ngtsc/file_system", "//packages/compiler-cli/src/ngtsc/metadata", "//packages/core/schematics/migrations/signal-migration/src", "//packages/core/schematics/utils/tsurge", diff --git a/packages/language-service/src/refactorings/convert_to_signal_input.ts b/packages/language-service/src/refactorings/convert_to_signal_input.ts index 4f9360f453c25..7f6b2039955cf 100644 --- a/packages/language-service/src/refactorings/convert_to_signal_input.ts +++ b/packages/language-service/src/refactorings/convert_to_signal_input.ts @@ -8,6 +8,7 @@ import {CompilerOptions} from '@angular/compiler-cli'; import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; +import {getFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system'; import {MetaKind} from '@angular/compiler-cli/src/ngtsc/metadata'; import { ClassIncompatibilityReason, @@ -103,6 +104,7 @@ export class ConvertToSignalInputRefactoring implements ActiveRefactoring { } reportProgress(0, 'Starting input migration. Analyzing..'); + const fs = getFileSystem(); const migration = new SignalInputMigration({ upgradeAnalysisPhaseToAvoidBatch: true, reportProgressFn: reportProgress, @@ -115,7 +117,7 @@ export class ConvertToSignalInputRefactoring implements ActiveRefactoring { program: compiler.getCurrentProgram(), userOptions: compilerOptions, programAbsoluteRootPaths: [], - tsconfigAbsolutePath: '', + tsconfigAbsolutePath: '/', }), ); @@ -126,7 +128,7 @@ export class ConvertToSignalInputRefactoring implements ActiveRefactoring { }; } - const {knownInputs, replacements} = migration.upgradedAnalysisPhaseResults; + const {knownInputs, replacements, projectAbsDirPath} = migration.upgradedAnalysisPhaseResults; const targetInput = Array.from(knownInputs.knownInputIds.values()).find( (i) => i.descriptor.node === containingProp, ); @@ -158,9 +160,9 @@ export class ConvertToSignalInputRefactoring implements ActiveRefactoring { } const fileUpdates = Array.from(groupReplacementsByFile(replacements).entries()); - const edits: ts.FileTextChanges[] = fileUpdates.map(([fileName, changes]) => { + const edits: ts.FileTextChanges[] = fileUpdates.map(([relativePath, changes]) => { return { - fileName, + fileName: fs.join(projectAbsDirPath, relativePath), textChanges: changes.map((c) => ({ newText: c.data.toInsert, span: { diff --git a/packages/language-service/test/signal_input_refactoring_action_spec.ts b/packages/language-service/test/signal_input_refactoring_action_spec.ts index bcfacceebe4ba..458f7bd68c0bb 100644 --- a/packages/language-service/test/signal_input_refactoring_action_spec.ts +++ b/packages/language-service/test/signal_input_refactoring_action_spec.ts @@ -107,7 +107,7 @@ describe('Signal input refactoring action', () => { expect(edits?.notApplicableReason).toBeUndefined(); expect(edits?.edits).toEqual([ { - fileName: 'app.ts', + fileName: '/test/app.ts', textChanges: [ // Input declaration. { From f694acb58756f109f9e631e249f736c3bdad2e5a Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Wed, 4 Sep 2024 14:04:22 +0000 Subject: [PATCH 37/41] refactor(language-service): improve error messaging for signal input refactoring (#57659) Instead of printing the enum name as the reason why migration did not complete, we should print some human-readable descriptions. This commit implements this. This logic may also be useful for the devkit comment generation, or CLI usage. In addition, we expose another VSCode refactoring to try via best effort mode. There is no way for prompting, or adding multiple actions for the same refactoring, so we expose a new refactoring. PR Close #57659 --- .../src/input_detection/incompatibility.md | 85 -------------- .../input_detection/incompatibility_human.ts | 106 ++++++++++++++++++ .../refactorings/convert_to_signal_input.ts | 64 +++++++---- .../src/refactorings/refactoring.ts | 10 +- .../signal_input_refactoring_action_spec.ts | 10 +- 5 files changed, 164 insertions(+), 111 deletions(-) delete mode 100644 packages/core/schematics/migrations/signal-migration/src/input_detection/incompatibility.md create mode 100644 packages/core/schematics/migrations/signal-migration/src/input_detection/incompatibility_human.ts diff --git a/packages/core/schematics/migrations/signal-migration/src/input_detection/incompatibility.md b/packages/core/schematics/migrations/signal-migration/src/input_detection/incompatibility.md deleted file mode 100644 index d1651ff706112..0000000000000 --- a/packages/core/schematics/migrations/signal-migration/src/input_detection/incompatibility.md +++ /dev/null @@ -1,85 +0,0 @@ -# Possible reasons why an `@Input` is not migrated - -The signal input migration may skip migration of `@Input()` declarations if the automated refactoring isn't possible or safe. -This document explains some of the reasons, or better known "migration incompatibilities". - -### `REASON:Accessor` -The input is declared using a getter/setter. -This is non-trivial to migrate without knowing the intent. - -### `REASON:WriteAssignment` -Parts of your application write to this input class field. -This blocks the automated migration because signal inputs cannot be modified programmatically. - -### `REASON:OverriddenByDerivedClass` -The input is part of a class that is extended by another class which overrides the field. -Migrating the input would then cause type conflicts and break the application build. - -```ts -@Component() -class MyComp { - @Input() myInput = true; -} - -class Derived extends MyComp { - override myInput = false; // <-- this wouldn't be a signal input and break! -} -``` - -### `REASON:RedeclaredViaDerivedClassInputsArray` -The input is part of a class that is extended by another class which overrides the field via the `inputs` array of `@Component` or `@Directive`. -Migrating the input would cause a mismatch because fields declared via `inputs` cannot be signal inputs. - -```ts -@Component() -class MyComp { - @Input() myInput = true; -} - -@Component({ - inputs: ['myInput: aliasedName'] -}) -class Derived extends MyComp {} -``` - -### `REASON:TypeConflictWithBaseClass` -The input is overriding a field from a superclass, but the superclass field is not an Angular `@Input` and is not migrated. -This results in a type conflict and would break the build. - -```ts -interface Parent { - disabled: boolean; -} - -@Component() -class MyComp implements Parent { - @Input() disabled = true; -} -``` - -### `REASON:ParentIsIncompatible` -The input is overriding a field from a superclass, but the superclass field could not be migrated. -This means that migrating the input would break the build. - -### `REASON:SpyOnThatOverwritesField` -The input can be migrated, but a Jasmine `spyOn` call for the input field was discovered. -`spyOn` calls are incompatible with signal inputs because they attempt to override the value of the field. -Signal inputs cannot be changed programmatically though— so this breaks. - -### `REASON:PotentiallyNarrowedInTemplateButNoSupportYet` -The input is part of an `@if` or `*ngIf`, or template input in general. -This indicates that the input value type may be narrowed. - -The migration skips migrating such inputs because support for narrowed signals is not available yet. - -### `REASON:RequiredInputButNoGoodExplicitTypeExtractable` -The input is required, but cannot be safely migrated because no good type could be detected. - -A required input with initial value doesn't make sense. -The type is inferred with `@Input` via the initial value, but this isn't possible with `input.required`. - -```ts -class MyComp { - @Input({required: true}) bla = someComplexInitialValue(); -} -``` diff --git a/packages/core/schematics/migrations/signal-migration/src/input_detection/incompatibility_human.ts b/packages/core/schematics/migrations/signal-migration/src/input_detection/incompatibility_human.ts new file mode 100644 index 0000000000000..136bf8a1b92a5 --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/src/input_detection/incompatibility_human.ts @@ -0,0 +1,106 @@ +/** + * @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.io/license + */ + +import {ClassIncompatibilityReason, InputIncompatibilityReason} from './incompatibility'; + +/** + * Gets human-readable message information for the given input incompatibility. + * This text will be used by the language service, or CLI-based migration. + */ +export function getMessageForInputIncompatibility(reason: InputIncompatibilityReason): { + short: string; + extra: string; +} { + switch (reason) { + case InputIncompatibilityReason.Accessor: + return { + short: 'Accessor inputs cannot be migrated as they are too complex.', + extra: + 'The migration potentially requires usage of `effect` or `computed`, but ' + + 'the intent is unclear. The migration cannot safely migrate.', + }; + case InputIncompatibilityReason.OverriddenByDerivedClass: + return { + short: 'The input cannot be migrated because the field is overridden by a subclass.', + extra: 'The field in the subclass is not an input, so migrating would break your build.', + }; + case InputIncompatibilityReason.ParentIsIncompatible: + return { + short: 'This input is inherited from a superclass, but the parent cannot be migrated.', + extra: 'Migrating this input would cause your build to fail.', + }; + case InputIncompatibilityReason.PotentiallyNarrowedInTemplateButNoSupportYet: + return { + short: + 'This input is used in a control flow expression (e.g. `@if` or `*ngIf`) and ' + + 'migrating would break narrowing currently.', + extra: `In the future, Angular intends to support narrowing of signals.`, + }; + case InputIncompatibilityReason.RedeclaredViaDerivedClassInputsArray: + return { + short: 'The input is overridden by a subclass that cannot be migrated.', + extra: + 'The subclass re-declares this input via the `inputs` array in @Directive/@Component. ' + + 'Migrating this input would break your build because the subclass input cannot be migrated.', + }; + case InputIncompatibilityReason.RequiredInputButNoGoodExplicitTypeExtractable: + return { + short: `Input is required, but the migration cannot determine a good type for the input.`, + extra: 'Consider adding an explicit type to make the migration possible.', + }; + case InputIncompatibilityReason.SkippedViaConfigFilter: + return { + short: `This input is not part of the current migration scope.`, + extra: 'Skipped via migration config.', + }; + case InputIncompatibilityReason.SpyOnThatOverwritesField: + return { + short: 'A jasmine `spyOn` call spies on this input. This breaks with signal inputs.', + extra: `Migration cannot safely migrate as "spyOn" writes to the input. Signal inputs are readonly.`, + }; + case InputIncompatibilityReason.TypeConflictWithBaseClass: + return { + short: + 'This input overrides a field from a superclass, while the superclass field is not migrated.', + extra: 'Migrating the input would break your build because of a type conflict then.', + }; + case InputIncompatibilityReason.WriteAssignment: + return { + short: 'Your application code writes to the input. This prevents migration.', + extra: 'Signal inputs are readonly, so migrating would break your build.', + }; + } +} + +/** + * Gets human-readable message information for the given input class incompatibility. + * This text will be used by the language service, or CLI-based migration. + */ +export function getMessageForClassIncompatibility(reason: ClassIncompatibilityReason): { + short: string; + extra: string; +} { + switch (reason) { + case ClassIncompatibilityReason.InputOwningClassReferencedInClassProperty: + return { + short: 'Class of this input is referenced in the signature of another class.', + extra: + 'The other class is likely typed to expect a non-migrated field, so ' + + 'migration is skipped to not break your build.', + }; + case ClassIncompatibilityReason.ClassManuallyInstantiated: + return { + short: + 'Class of this input is manually instantiated (`new Cmp()`). ' + + 'This is discouraged and prevents migration', + extra: + 'Signal inputs require a DI injection context. Manually instantiating ' + + 'breaks this requirement in some cases, so the migration is skipped.', + }; + } +} diff --git a/packages/language-service/src/refactorings/convert_to_signal_input.ts b/packages/language-service/src/refactorings/convert_to_signal_input.ts index 7f6b2039955cf..a21b2b8f1d11d 100644 --- a/packages/language-service/src/refactorings/convert_to_signal_input.ts +++ b/packages/language-service/src/refactorings/convert_to_signal_input.ts @@ -11,29 +11,29 @@ import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; import {getFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system'; import {MetaKind} from '@angular/compiler-cli/src/ngtsc/metadata'; import { - ClassIncompatibilityReason, - InputIncompatibilityReason, -} from '@angular/core/schematics/migrations/signal-migration/src/input_detection/incompatibility'; + getMessageForClassIncompatibility, + getMessageForInputIncompatibility, +} from '@angular/core/schematics/migrations/signal-migration/src/input_detection/incompatibility_human'; import {ApplyRefactoringProgressFn} from '@angular/language-service/api'; import ts from 'typescript'; -import {isInputContainerNode} from '../../../core/schematics/migrations/signal-migration/src/input_detection/input_node'; -import {SignalInputMigration} from '../../../core/schematics/migrations/signal-migration/src/migration'; -import {groupReplacementsByFile} from '../../../core/schematics/utils/tsurge/helpers/group_replacements'; +import {isInputContainerNode} from '@angular/core/schematics/migrations/signal-migration/src/input_detection/input_node'; +import {SignalInputMigration} from '@angular/core/schematics/migrations/signal-migration/src/migration'; +import {MigrationConfig} from '@angular/core/schematics/migrations/signal-migration/src/migration_config'; +import {groupReplacementsByFile} from '@angular/core/schematics/utils/tsurge/helpers/group_replacements'; +import {isTypeScriptFile} from '../utils'; import {findTightestNode, getParentClassDeclaration} from '../utils/ts_utils'; import type {ActiveRefactoring} from './refactoring'; -import {isTypeScriptFile} from '../utils'; /** - * Language service refactoring action that can convert `@Input()` + * Base language service refactoring action that can convert `@Input()` * declarations to signal inputs. * * The user can click on an `@Input` property declaration in e.g. the VSCode * extension and ask for the input to be migrated. All references, imports and * the declaration are updated automatically. */ -export class ConvertToSignalInputRefactoring implements ActiveRefactoring { - static id = 'convert-to-signal-input'; - static description = '(experimental fixer): Convert @Input() to a signal input'; +abstract class BaseConvertToSignalInputRefactoring implements ActiveRefactoring { + abstract config: MigrationConfig; static isApplicable( compiler: NgCompiler, @@ -106,6 +106,7 @@ export class ConvertToSignalInputRefactoring implements ActiveRefactoring { const fs = getFileSystem(); const migration = new SignalInputMigration({ + ...this.config, upgradeAnalysisPhaseToAvoidBatch: true, reportProgressFn: reportProgress, shouldMigrateInput: (input) => input.descriptor.node === containingProp, @@ -145,17 +146,29 @@ export class ConvertToSignalInputRefactoring implements ActiveRefactoring { const {container, descriptor} = targetInput; const memberIncompatibility = container.memberIncompatibility.get(descriptor.key); const classIncompatibility = container.incompatible; - + const aggressiveModeRecommendation = !this.config.bestEffortMode + ? `\n—— Consider using the "(forcibly, ignoring errors)" action to forcibly convert.` + : ''; + + if (memberIncompatibility !== undefined) { + const {short, extra} = getMessageForInputIncompatibility(memberIncompatibility.reason); + return { + edits: [], + notApplicableReason: `${short}\n${extra}${aggressiveModeRecommendation}`, + }; + } + if (classIncompatibility !== null) { + const {short, extra} = getMessageForClassIncompatibility(classIncompatibility); + return { + edits: [], + notApplicableReason: `${short}\n${extra}${aggressiveModeRecommendation}`, + }; + } return { edits: [], - // TODO: Output a better human-readable message here. For now this is better than a noop. - notApplicableReason: `Input cannot be migrated: ${ - memberIncompatibility !== undefined - ? InputIncompatibilityReason[memberIncompatibility.reason] - : classIncompatibility !== null - ? ClassIncompatibilityReason[classIncompatibility] - : 'unknown' - }`, + notApplicableReason: + 'Input cannot be migrated, but no reason was found. ' + + 'Consider reporting a bug to the Angular team.', }; } @@ -184,6 +197,17 @@ export class ConvertToSignalInputRefactoring implements ActiveRefactoring { } } +export class ConvertToSignalInputRefactoring extends BaseConvertToSignalInputRefactoring { + static id = 'convert-to-signal-input-safe-mode'; + static description = 'Convert this @Input() to a signal input (safe)'; + override config: MigrationConfig = {}; +} +export class ConvertToSignalInputBestEffortRefactoring extends BaseConvertToSignalInputRefactoring { + static id = 'convert-to-signal-input-best-effort-mode'; + static description = 'Convert @Input() to a signal input (forcibly, ignoring errors)'; + override config: MigrationConfig = {bestEffortMode: true}; +} + function findParentPropertyDeclaration(node: ts.Node): ts.PropertyDeclaration | null { while (!ts.isPropertyDeclaration(node) && !ts.isSourceFile(node)) { node = node.parent; diff --git a/packages/language-service/src/refactorings/refactoring.ts b/packages/language-service/src/refactorings/refactoring.ts index ed0b60cbefa67..327e2219a0d22 100644 --- a/packages/language-service/src/refactorings/refactoring.ts +++ b/packages/language-service/src/refactorings/refactoring.ts @@ -10,7 +10,10 @@ import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; import ts from 'typescript'; import {ApplyRefactoringProgressFn} from '@angular/language-service/api'; import {CompilerOptions} from '@angular/compiler-cli'; -import {ConvertToSignalInputRefactoring} from './convert_to_signal_input'; +import { + ConvertToSignalInputBestEffortRefactoring, + ConvertToSignalInputRefactoring, +} from './convert_to_signal_input'; /** * Interface exposing static metadata for a {@link Refactoring}, @@ -60,4 +63,7 @@ export interface ActiveRefactoring { ): Promise; } -export const allRefactorings: Refactoring[] = [ConvertToSignalInputRefactoring]; +export const allRefactorings: Refactoring[] = [ + ConvertToSignalInputRefactoring, + ConvertToSignalInputBestEffortRefactoring, +]; diff --git a/packages/language-service/test/signal_input_refactoring_action_spec.ts b/packages/language-service/test/signal_input_refactoring_action_spec.ts index 458f7bd68c0bb..2a7168e6cdb1d 100644 --- a/packages/language-service/test/signal_input_refactoring_action_spec.ts +++ b/packages/language-service/test/signal_input_refactoring_action_spec.ts @@ -34,8 +34,9 @@ describe('Signal input refactoring action', () => { appFile.moveCursorToText('bl¦a'); const refactorings = project.getRefactoringsAtPosition('app.ts', appFile.cursor); - expect(refactorings.length).toBe(1); - expect(refactorings[0].name).toBe('convert-to-signal-input'); + expect(refactorings.length).toBe(2); + expect(refactorings[0].name).toBe('convert-to-signal-input-safe-mode'); + expect(refactorings[1].name).toBe('convert-to-signal-input-best-effort-mode'); }); it('should not support refactoring a non-Angular property', () => { @@ -95,8 +96,9 @@ describe('Signal input refactoring action', () => { appFile.moveCursorToText('bl¦a'); const refactorings = project.getRefactoringsAtPosition('app.ts', appFile.cursor); - expect(refactorings.length).toBe(1); - expect(refactorings[0].name).toBe('convert-to-signal-input'); + expect(refactorings.length).toBe(2); + expect(refactorings[0].name).toBe('convert-to-signal-input-safe-mode'); + expect(refactorings[1].name).toBe('convert-to-signal-input-best-effort-mode'); const edits = await project.applyRefactoring( 'app.ts', From aa439662af4ba0bbc83c17cb0e8e7514aad35d01 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Wed, 4 Sep 2024 14:06:32 +0000 Subject: [PATCH 38/41] refactor(migrations): speed up migration for subset of signal inputs (#57659) With the preparation work from previous commits, we are able to reduce analysis time of the migration from e.g. whole Material repo 7seconds to 0.1seconds when the migration is invoked via the VSCode extension. This is possible because we can avoid many expensive type checking lookups if we know what inputs are actually migrated. We do this by adding some naive pre-check to see if identifiers are possibly pointing to a migrated input. This is possible now because we no longer migrate aliased identifiers for object expansion, but instead migrate directly at object expansion declaration. This allows us to assume that all possible references to inputs must go through identifiers that are named like the original input class field name. PR Close #57659 --- .../signal-migration/src/migration.ts | 44 +++--------------- .../signal-migration/src/migration_config.ts | 45 +++++++++++++++++++ .../signal-migration/src/migration_host.ts | 4 +- .../src/passes/1_identify_inputs.ts | 2 +- .../passes/2_find_source_file_references.ts | 23 ++++++++-- .../references/identify_ts_references.ts | 7 +++ 6 files changed, 80 insertions(+), 45 deletions(-) create mode 100644 packages/core/schematics/migrations/signal-migration/src/migration_config.ts diff --git a/packages/core/schematics/migrations/signal-migration/src/migration.ts b/packages/core/schematics/migrations/signal-migration/src/migration.ts index 63f085165c7a4..ec5c8bdda9583 100644 --- a/packages/core/schematics/migrations/signal-migration/src/migration.ts +++ b/packages/core/schematics/migrations/signal-migration/src/migration.ts @@ -28,42 +28,7 @@ import {createNgtscProgram} from '../../../utils/tsurge/helpers/ngtsc_program'; import assert from 'assert'; import {InputIncompatibilityReason} from './input_detection/incompatibility'; import {InputUniqueKey, isInputDescriptor} from './utils/input_id'; - -export interface MigrationConfig { - /** - * Whether to migrate as much as possible, even if certain inputs would otherwise - * be marked as incompatible for migration. - */ - bestEffortMode?: boolean; - - /** - * Whether the given input should be migrated. With batch execution, this - * callback fires for foreign inputs from other compilation units too. - * - * Treating the input as non-migrated means that no references to it are - * migrated. - */ - shouldMigrateInput?: (input: KnownInputInfo) => boolean; - - /** - * Whether to upgrade analysis phase to avoid batch execution. - * - * This is useful when not running against multiple compilation units. - * The analysis phase will re-use the same program and information, without - * re-analyzing in the `migrate` phase. - * - * Results will be available as {@link SignalInputMigration#upgradedAnalysisPhaseResults} - * after executing the analyze stage. - */ - upgradeAnalysisPhaseToAvoidBatch?: boolean; - - /** - * Optional function to receive updates on progress of the migration. Useful - * for integration with the language service to give some kind of indication - * what the migration is currently doing. - */ - reportProgressFn?: (percentage: number, updateMessage: string) => void; -} +import {MigrationConfig} from './migration_config'; /** * Tsurge migration for migrating Angular `@Input()` declarations to @@ -111,7 +76,7 @@ export class SignalInputMigration extends TsurgeComplexMigration< const {metaRegistry} = analysisDeps; const knownInputs = new KnownInputs(); const result = new MigrationResult(); - const host = createMigrationHost(info); + const host = createMigrationHost(info, this.config); this.config.reportProgressFn?.(10, 'Analyzing project (input usages)..'); const {inheritanceGraph} = executeAnalysisPhase(host, knownInputs, result, analysisDeps); @@ -166,7 +131,7 @@ export class SignalInputMigration extends TsurgeComplexMigration< ): Promise { const knownInputs = nonBatchData?.knownInputs ?? new KnownInputs(); const result = nonBatchData?.result ?? new MigrationResult(); - const host = nonBatchData?.host ?? createMigrationHost(info); + const host = nonBatchData?.host ?? createMigrationHost(info, this.config); const analysisDeps = nonBatchData?.analysisDeps ?? this.prepareAnalysisDeps(info); let inheritanceGraph: InheritanceGraph; @@ -231,11 +196,12 @@ function filterInputsViaConfig( }); } -function createMigrationHost(info: ProgramInfo): MigrationHost { +function createMigrationHost(info: ProgramInfo, config: MigrationConfig): MigrationHost { return new MigrationHost( /* projectDir */ info.projectDirAbsPath, /* isMigratingCore */ false, info.userOptions, + config, info.sourceFiles, ); } diff --git a/packages/core/schematics/migrations/signal-migration/src/migration_config.ts b/packages/core/schematics/migrations/signal-migration/src/migration_config.ts new file mode 100644 index 0000000000000..e9db24fe3f7a1 --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/src/migration_config.ts @@ -0,0 +1,45 @@ +/** + * @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.io/license + */ + +import type {KnownInputInfo} from './input_detection/known_inputs'; + +export interface MigrationConfig { + /** + * Whether to migrate as much as possible, even if certain inputs would otherwise + * be marked as incompatible for migration. + */ + bestEffortMode?: boolean; + + /** + * Whether the given input should be migrated. With batch execution, this + * callback fires for foreign inputs from other compilation units too. + * + * Treating the input as non-migrated means that no references to it are + * migrated. + */ + shouldMigrateInput?: (input: KnownInputInfo) => boolean; + + /** + * Whether to upgrade analysis phase to avoid batch execution. + * + * This is useful when not running against multiple compilation units. + * The analysis phase will re-use the same program and information, without + * re-analyzing in the `migrate` phase. + * + * Results will be available as {@link SignalInputMigration#upgradedAnalysisPhaseResults} + * after executing the analyze stage. + */ + upgradeAnalysisPhaseToAvoidBatch?: boolean; + + /** + * Optional function to receive updates on progress of the migration. Useful + * for integration with the language service to give some kind of indication + * what the migration is currently doing. + */ + reportProgressFn?: (percentage: number, updateMessage: string) => void; +} diff --git a/packages/core/schematics/migrations/signal-migration/src/migration_host.ts b/packages/core/schematics/migrations/signal-migration/src/migration_host.ts index 56a27482035ee..c0cc70512dd23 100644 --- a/packages/core/schematics/migrations/signal-migration/src/migration_host.ts +++ b/packages/core/schematics/migrations/signal-migration/src/migration_host.ts @@ -9,6 +9,7 @@ import path from 'path'; import ts from 'typescript'; import {NgCompilerOptions} from '@angular/compiler-cli/src/ngtsc/core/api'; +import {MigrationConfig} from './migration_config'; /** * A migration host is in practice a container object that @@ -21,7 +22,8 @@ export class MigrationHost { constructor( public projectDir: string, public isMigratingCore: boolean, - public options: NgCompilerOptions, + public compilerOptions: NgCompilerOptions, + public config: MigrationConfig, sourceFiles: readonly ts.SourceFile[], ) { this._sourceFiles = new WeakSet(sourceFiles); diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/1_identify_inputs.ts b/packages/core/schematics/migrations/signal-migration/src/passes/1_identify_inputs.ts index a96c2d88165f4..e6ab3b63a36ea 100644 --- a/packages/core/schematics/migrations/signal-migration/src/passes/1_identify_inputs.ts +++ b/packages/core/schematics/migrations/signal-migration/src/passes/1_identify_inputs.ts @@ -59,7 +59,7 @@ export function pass1__IdentifySourceFileAndDeclarationInputs( node, decoratorInput, checker, - host.options, + host.compilerOptions, ); if (isInputMemberIncompatibility(conversionPreparation)) { diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/2_find_source_file_references.ts b/packages/core/schematics/migrations/signal-migration/src/passes/2_find_source_file_references.ts index 0ec8c44f40630..5eed96bac6722 100644 --- a/packages/core/schematics/migrations/signal-migration/src/passes/2_find_source_file_references.ts +++ b/packages/core/schematics/migrations/signal-migration/src/passes/2_find_source_file_references.ts @@ -53,6 +53,13 @@ export function pass2_IdentifySourceFileReferences( knownInputs, ); + // List of input field names that will be migrated. + const migratedInputFieldNames = new Set( + Array.from(knownInputs.knownInputIds.values()) + .filter((v) => host.config.shouldMigrateInput?.(v) ?? true) + .map((v) => v.descriptor.node.name.text), + ); + const perfCounters = { template: 0, hostBindings: 0, @@ -72,7 +79,7 @@ export function pass2_IdentifySourceFileReferences( evaluator, templateTypeChecker, resourceLoader, - host.options, + host.compilerOptions, result, knownInputs, ); @@ -92,9 +99,17 @@ export function pass2_IdentifySourceFileReferences( ts.isIdentifier(node) && !(isInputContainerNode(node.parent) && node.parent.name === node) ) { - identifyPotentialTypeScriptReference(node, host, checker, knownInputs, result, { - debugElComponentInstanceTracker, - }); + identifyPotentialTypeScriptReference( + node, + host, + checker, + knownInputs, + result, + migratedInputFieldNames, + { + debugElComponentInstanceTracker, + }, + ); } perfCounters.tsReferences += (performance.now() - lastTime) / 1000; diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/references/identify_ts_references.ts b/packages/core/schematics/migrations/signal-migration/src/passes/references/identify_ts_references.ts index 15f597a3bc28f..076f4d7795542 100644 --- a/packages/core/schematics/migrations/signal-migration/src/passes/references/identify_ts_references.ts +++ b/packages/core/schematics/migrations/signal-migration/src/passes/references/identify_ts_references.ts @@ -30,10 +30,17 @@ export function identifyPotentialTypeScriptReference( checker: ts.TypeChecker, knownInputs: KnownInputs, result: MigrationResult, + migratedInputFieldNames: Set, advisors: { debugElComponentInstanceTracker: DebugElementComponentInstance; }, ): void { + // Skip all identifiers that never can point to a migrated input. + // TODO: Capture these assumptions and performance optimizations in the design doc. + if (!migratedInputFieldNames.has(node.text)) { + return; + } + let target: ts.Symbol | undefined = undefined; // Resolve binding elements to their declaration symbol. From c3f2420877b48b32492c73c580b0eb36f002bf0e Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Wed, 4 Sep 2024 14:44:31 +0000 Subject: [PATCH 39/41] refactor(migrations): use proper suffix for object expansion input variables (#57659) Whenever we migrate object expansion patterns, we may need a temporary variable to generate a construct like: ```ts const {bla: blaValue} = this; const bla = blaValue(); ``` We should instead use `blaInput` as the name for the temporary variable. For narrowing constants, `blaValue` is correct, but here it's an actual reference to the input / signal. PR Close #57659 --- .../src/passes/5_migrate_ts_references.ts | 17 ++------------- .../object_expansion_refs.ts | 21 ++++++++++++------- .../standard_reference.ts | 3 ++- .../src/utils/unique_names.ts | 7 +++---- .../signal-migration/test/golden.txt | 16 +++++++------- .../test/golden_best_effort.txt | 16 +++++++------- 6 files changed, 37 insertions(+), 43 deletions(-) diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/5_migrate_ts_references.ts b/packages/core/schematics/migrations/signal-migration/src/passes/5_migrate_ts_references.ts index a1e0e23a477f6..fdab4a638249d 100644 --- a/packages/core/schematics/migrations/signal-migration/src/passes/5_migrate_ts_references.ts +++ b/packages/core/schematics/migrations/signal-migration/src/passes/5_migrate_ts_references.ts @@ -12,7 +12,6 @@ import {KnownInputs} from '../input_detection/known_inputs'; import {MigrationResult} from '../result'; import {InputUniqueKey} from '../utils/input_id'; import {isTsInputReference} from '../utils/input_reference'; -import {UniqueNamesGenerator} from '../utils/unique_names'; import { migrateBindingElementInputReference, IdentifierOfBindingElement, @@ -57,7 +56,6 @@ export function pass5__migrateTypeScriptReferences( const tsReferencesInBindingElements = new Set(); const seenIdentifiers = new WeakSet(); - const nameGenerator = new UniqueNamesGenerator(); for (const reference of result.references) { // This pass only deals with TS references. @@ -92,18 +90,7 @@ export function pass5__migrateTypeScriptReferences( } } - migrateBindingElementInputReference( - tsReferencesInBindingElements, - projectDirAbsPath, - nameGenerator, - result, - ); + migrateBindingElementInputReference(tsReferencesInBindingElements, projectDirAbsPath, result); - migrateStandardTsReference( - tsReferencesWithNarrowing, - checker, - result, - nameGenerator, - projectDirAbsPath, - ); + migrateStandardTsReference(tsReferencesWithNarrowing, checker, result, projectDirAbsPath); } diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/object_expansion_refs.ts b/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/object_expansion_refs.ts index 1a6df5c07724a..17d693bf8b765 100644 --- a/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/object_expansion_refs.ts +++ b/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/object_expansion_refs.ts @@ -44,9 +44,10 @@ export interface IdentifierOfBindingElement extends ts.Identifier { export function migrateBindingElementInputReference( tsReferencesInBindingElements: Set, projectDirAbsPath: AbsoluteFsPath, - nameGenerator: UniqueNamesGenerator, result: MigrationResult, ) { + const nameGenerator = new UniqueNamesGenerator(['Input', 'Signal', 'Ref']); + for (const reference of tsReferencesInBindingElements) { const bindingElement = reference.parent; const bindingDecl = getBindingElementDeclaration(bindingElement); @@ -64,12 +65,14 @@ export function migrateBindingElementInputReference( // Only use the temporary name, if really needed. A temporary name is needed if // the input field simply aliased via the binding element, or if the exposed identifier // is a string-literal like. - const useTmpName = + const useTmpNameForInputField = !ts.isObjectBindingPattern(bindingElement.name) || !ts.isIdentifier(inputFieldName); - const propertyName = useTmpName ? inputFieldName : undefined; - const exposedName = useTmpName ? ts.factory.createIdentifier(tmpName) : inputFieldName; - const newBinding = ts.factory.updateBindingElement( + const propertyName = useTmpNameForInputField ? inputFieldName : undefined; + const exposedName = useTmpNameForInputField + ? ts.factory.createIdentifier(tmpName) + : inputFieldName; + const newBindingToAccessInputField = ts.factory.updateBindingElement( bindingElement, bindingElement.dotDotDotToken, propertyName, @@ -80,7 +83,7 @@ export function migrateBindingElementInputReference( const temporaryVariableReplacements = insertTemporaryVariableForBindingElement( bindingDecl, filePath, - `const ${bindingElement.name.getText()} = ${tmpName}();`, + `const ${bindingElement.name.getText()} = ${exposedName.text}();`, ); if (temporaryVariableReplacements === null) { console.error(`Could not migrate reference ${reference.text} in ${filePath}`); @@ -93,7 +96,11 @@ export function migrateBindingElementInputReference( new TextUpdate({ position: bindingElement.getStart(), end: bindingElement.getEnd(), - toInsert: result.printer.printNode(ts.EmitHint.Unspecified, newBinding, sourceFile), + toInsert: result.printer.printNode( + ts.EmitHint.Unspecified, + newBindingToAccessInputField, + sourceFile, + ), }), ), ...temporaryVariableReplacements, diff --git a/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/standard_reference.ts b/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/standard_reference.ts index a9a07f7937920..24dcdfd0ab0d4 100644 --- a/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/standard_reference.ts +++ b/packages/core/schematics/migrations/signal-migration/src/passes/migrate_ts_reference/standard_reference.ts @@ -24,9 +24,10 @@ export function migrateStandardTsReference( tsReferencesWithNarrowing: Map, checker: ts.TypeChecker, result: MigrationResult, - nameGenerator: UniqueNamesGenerator, projectDirAbsPath: AbsoluteFsPath, ) { + const nameGenerator = new UniqueNamesGenerator(['Value', 'Val', 'Input']); + // TODO: Consider checking/properly handling optional chaining and narrowing. for (const reference of tsReferencesWithNarrowing.values()) { const controlFlowResult = analyzeControlFlow(reference.accesses, checker); diff --git a/packages/core/schematics/migrations/signal-migration/src/utils/unique_names.ts b/packages/core/schematics/migrations/signal-migration/src/utils/unique_names.ts index 1c816e569629f..40ca5ed97d603 100644 --- a/packages/core/schematics/migrations/signal-migration/src/utils/unique_names.ts +++ b/packages/core/schematics/migrations/signal-migration/src/utils/unique_names.ts @@ -9,9 +9,6 @@ import ts from 'typescript'; import {isIdentifierFreeInScope} from './is_identifier_free_in_scope'; -/** List of potential suffixes to avoid conflicts. */ -const fallbackSuffixes = ['Value', 'Val', 'Input']; - /** * Helper that can generate unique identifier names at a * given location. @@ -20,6 +17,8 @@ const fallbackSuffixes = ['Value', 'Val', 'Input']; * to support narrowing. */ export class UniqueNamesGenerator { + constructor(private readonly fallbackSuffixes: string[]) {} + generate(base: string, location: ts.Node): string { const checkNameAndClaimIfAvailable = (name: string): boolean => { const freeInfo = isIdentifierFreeInScope(name, location); @@ -38,7 +37,7 @@ export class UniqueNamesGenerator { } // Try any of the possible suffixes. - for (const suffix of fallbackSuffixes) { + for (const suffix of this.fallbackSuffixes) { const name = `${base}${suffix}`; if (checkNameAndClaimIfAvailable(name)) { return name; diff --git a/packages/core/schematics/migrations/signal-migration/test/golden.txt b/packages/core/schematics/migrations/signal-migration/test/golden.txt index a0a6c228aaa6d..2d8c98ae6e531 100644 --- a/packages/core/schematics/migrations/signal-migration/test/golden.txt +++ b/packages/core/schematics/migrations/signal-migration/test/golden.txt @@ -514,8 +514,8 @@ class IndexAccessInput { readonly items = input([]); bla() { - const {items: itemsValue} = this; - const items = itemsValue(); + const {items: itemsInput} = this; + const items = itemsInput(); items[0].charAt(0); } @@ -724,8 +724,8 @@ export class Narrowing { } narrowingObjectExpansion() { - [this].map(({name: nameValue}) => { - const name = nameValue(); + [this].map(({name: nameInput}) => { + const name = nameInput(); return name && name.charAt(0); }); } @@ -786,8 +786,8 @@ export class ObjectExpansion { readonly bla = input(''); expansion() { - const {bla: blaValue} = this; - const bla = blaValue(); + const {bla: blaInput} = this; + const bla = blaInput(); bla.charAt(0); } @@ -800,8 +800,8 @@ export class ObjectExpansion { charAt(0); } - expansionAsParameter({bla: blaValue} = this) { - const bla = blaValue(); + expansionAsParameter({bla: blaInput} = this) { + const bla = blaInput(); bla.charAt(0); } } diff --git a/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt b/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt index a071550574815..faf6e8706550e 100644 --- a/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt +++ b/packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt @@ -514,8 +514,8 @@ class IndexAccessInput { readonly items = input([]); bla() { - const {items: itemsValue} = this; - const items = itemsValue(); + const {items: itemsInput} = this; + const items = itemsInput(); items[0].charAt(0); } @@ -724,8 +724,8 @@ export class Narrowing { } narrowingObjectExpansion() { - [this].map(({name: nameValue}) => { - const name = nameValue(); + [this].map(({name: nameInput}) => { + const name = nameInput(); return name && name.charAt(0); }); } @@ -786,8 +786,8 @@ export class ObjectExpansion { readonly bla = input(''); expansion() { - const {bla: blaValue} = this; - const bla = blaValue(); + const {bla: blaInput} = this; + const bla = blaInput(); bla.charAt(0); } @@ -800,8 +800,8 @@ export class ObjectExpansion { charAt(0); } - expansionAsParameter({bla: blaValue} = this) { - const bla = blaValue(); + expansionAsParameter({bla: blaInput} = this) { + const bla = blaInput(); bla.charAt(0); } } From f6c40f1ba181c3d10af57774282292c8de4e3223 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Wed, 4 Sep 2024 15:40:35 +0000 Subject: [PATCH 40/41] refactor(migrations): expose all input migration helpers (#57659) Instead of encouraging deep imports, we should expose commonly accessed exports in a `index.ts` barrel file. PR Close #57659 --- .../signal-migration/src/BUILD.bazel | 2 +- .../migrations/signal-migration/src/cli.ts | 42 +++++++++++++++ .../migrations/signal-migration/src/index.ts | 52 +++++++------------ .../refactorings/convert_to_signal_input.ts | 12 ++--- 4 files changed, 67 insertions(+), 41 deletions(-) create mode 100644 packages/core/schematics/migrations/signal-migration/src/cli.ts diff --git a/packages/core/schematics/migrations/signal-migration/src/BUILD.bazel b/packages/core/schematics/migrations/signal-migration/src/BUILD.bazel index a1b62b832b535..dfef73ab47988 100644 --- a/packages/core/schematics/migrations/signal-migration/src/BUILD.bazel +++ b/packages/core/schematics/migrations/signal-migration/src/BUILD.bazel @@ -44,7 +44,7 @@ ts_library( nodejs_binary( name = "bin", data = [":src"], - entry_point = ":index.ts", + entry_point = ":cli.ts", visibility = ["//packages/core/schematics/migrations/signal-migration/test:__pkg__"], ) diff --git a/packages/core/schematics/migrations/signal-migration/src/cli.ts b/packages/core/schematics/migrations/signal-migration/src/cli.ts new file mode 100644 index 0000000000000..8656f428e6188 --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/src/cli.ts @@ -0,0 +1,42 @@ +/** + * @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.io/license + */ + +import path from 'path'; + +import assert from 'assert'; +import {SignalInputMigration} from './migration'; +import {writeMigrationReplacements} from './write_replacements'; + +main(path.resolve(process.argv[2]), process.argv.includes('--best-effort-mode')).catch((e) => { + console.error(e); + process.exitCode = 1; +}); + +/** + * Runs the signal input migration for the given TypeScript project. + */ +export async function main(absoluteTsconfigPath: string, bestEffortMode: boolean) { + const migration = new SignalInputMigration({ + bestEffortMode, + upgradeAnalysisPhaseToAvoidBatch: true, + }); + const baseInfo = migration.createProgram(absoluteTsconfigPath); + const info = migration.prepareProgram(baseInfo); + + await migration.analyze(info); + + assert( + migration.upgradedAnalysisPhaseResults, + 'Expected upgraded analysis phase results; batch mode is disabled.', + ); + + const {replacements, projectAbsDirPath} = migration.upgradedAnalysisPhaseResults; + + // Apply replacements + writeMigrationReplacements(replacements, projectAbsDirPath); +} diff --git a/packages/core/schematics/migrations/signal-migration/src/index.ts b/packages/core/schematics/migrations/signal-migration/src/index.ts index 8656f428e6188..50ce74ba7354f 100644 --- a/packages/core/schematics/migrations/signal-migration/src/index.ts +++ b/packages/core/schematics/migrations/signal-migration/src/index.ts @@ -6,37 +6,21 @@ * found in the LICENSE file at https://angular.io/license */ -import path from 'path'; - -import assert from 'assert'; -import {SignalInputMigration} from './migration'; -import {writeMigrationReplacements} from './write_replacements'; - -main(path.resolve(process.argv[2]), process.argv.includes('--best-effort-mode')).catch((e) => { - console.error(e); - process.exitCode = 1; -}); - -/** - * Runs the signal input migration for the given TypeScript project. - */ -export async function main(absoluteTsconfigPath: string, bestEffortMode: boolean) { - const migration = new SignalInputMigration({ - bestEffortMode, - upgradeAnalysisPhaseToAvoidBatch: true, - }); - const baseInfo = migration.createProgram(absoluteTsconfigPath); - const info = migration.prepareProgram(baseInfo); - - await migration.analyze(info); - - assert( - migration.upgradedAnalysisPhaseResults, - 'Expected upgraded analysis phase results; batch mode is disabled.', - ); - - const {replacements, projectAbsDirPath} = migration.upgradedAnalysisPhaseResults; - - // Apply replacements - writeMigrationReplacements(replacements, projectAbsDirPath); -} +export { + getMessageForClassIncompatibility, + getMessageForInputIncompatibility, +} from './input_detection/incompatibility_human'; +export {type KnownInputInfo, KnownInputs} from './input_detection/known_inputs'; +export { + type InputNameNode, + type InputNode, + isInputContainerNode, +} from './input_detection/input_node'; +export { + type InputDescriptor, + type InputUniqueKey, + getInputDescriptor, + isInputDescriptor, +} from './utils/input_id'; +export {SignalInputMigration} from './migration'; +export {type MigrationConfig} from './migration_config'; diff --git a/packages/language-service/src/refactorings/convert_to_signal_input.ts b/packages/language-service/src/refactorings/convert_to_signal_input.ts index a21b2b8f1d11d..330c5a3ecf7af 100644 --- a/packages/language-service/src/refactorings/convert_to_signal_input.ts +++ b/packages/language-service/src/refactorings/convert_to_signal_input.ts @@ -10,15 +10,15 @@ import {CompilerOptions} from '@angular/compiler-cli'; import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; import {getFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system'; import {MetaKind} from '@angular/compiler-cli/src/ngtsc/metadata'; +import {ApplyRefactoringProgressFn} from '@angular/language-service/api'; +import ts from 'typescript'; import { + isInputContainerNode, + SignalInputMigration, + MigrationConfig, getMessageForClassIncompatibility, getMessageForInputIncompatibility, -} from '@angular/core/schematics/migrations/signal-migration/src/input_detection/incompatibility_human'; -import {ApplyRefactoringProgressFn} from '@angular/language-service/api'; -import ts from 'typescript'; -import {isInputContainerNode} from '@angular/core/schematics/migrations/signal-migration/src/input_detection/input_node'; -import {SignalInputMigration} from '@angular/core/schematics/migrations/signal-migration/src/migration'; -import {MigrationConfig} from '@angular/core/schematics/migrations/signal-migration/src/migration_config'; +} from '@angular/core/schematics/migrations/signal-migration/src'; import {groupReplacementsByFile} from '@angular/core/schematics/utils/tsurge/helpers/group_replacements'; import {isTypeScriptFile} from '../utils'; import {findTightestNode, getParentClassDeclaration} from '../utils/ts_utils'; From 7a4199a29a83e3acd046d97508fd28feb9ec114d Mon Sep 17 00:00:00 2001 From: Sheik Althaf Date: Tue, 16 Jul 2024 17:11:17 +0530 Subject: [PATCH 41/41] refactor(devtools): use signal apis for directive forest (#56998) Refactor the directive-forest components to use signal apis, in future we can make the components onPush and zoneless PR Close #56998 --- .../directive-explorer.component.html | 16 +-- .../directive-explorer.component.ts | 115 ++++++++---------- .../directive-explorer.spec.ts | 54 ++++---- .../breadcrumbs/breadcrumbs.component.html | 8 +- .../breadcrumbs/breadcrumbs.component.scss | 4 +- .../breadcrumbs/breadcrumbs.component.ts | 82 +++++++------ .../directive-forest.component.html | 4 +- .../directive-forest.component.ts | 102 +++++++--------- .../filter/filter.component.html | 2 +- .../filter/filter.component.ts | 10 +- 10 files changed, 191 insertions(+), 206 deletions(-) diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.html b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.html index da5a29ac089ec..071adb53754fb 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.html +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.html @@ -1,4 +1,4 @@ - + @@ -9,18 +9,18 @@ (highlightComponent)="highlightComponent($event)" (removeComponentHighlight)="removeComponentHighlight()" (toggleInspector)="toggleInspector.emit()" - [forest]="forest" - [currentSelectedElement]="currentSelectedElement" - [showCommentNodes]="showCommentNodes" + [forest]="forest()" + [currentSelectedElement]="currentSelectedElement()" + [showCommentNodes]="showCommentNodes()" /> - @if (parents) { + @if (parents()) { } @@ -29,7 +29,7 @@
@@ -38,7 +38,7 @@
Show hydration overlays
diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.ts b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.ts index 6031f8f23be0f..03fe8fe485ec5 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.ts +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.component.ts @@ -7,17 +7,17 @@ */ import { - ChangeDetectorRef, + afterNextRender, Component, ElementRef, - EventEmitter, inject, Input, - NgZone, + input, OnDestroy, - OnInit, - Output, + output, + signal, ViewChild, + viewChild, } from '@angular/core'; import { ComponentExplorerView, @@ -86,57 +86,50 @@ const sameDirectives = (a: IndexedNode, b: IndexedNode) => { FormsModule, ], }) -export class DirectiveExplorerComponent implements OnInit, OnDestroy { - @Input() showCommentNodes = false; +export class DirectiveExplorerComponent implements OnDestroy { + readonly showCommentNodes = input(false); @Input() isHydrationEnabled = false; - @Output() toggleInspector = new EventEmitter(); + readonly toggleInspector = output(); - @ViewChild(DirectiveForestComponent) directiveForest!: DirectiveForestComponent; - @ViewChild(BreadcrumbsComponent) breadcrumbs!: BreadcrumbsComponent; + readonly directiveForest = viewChild(DirectiveForestComponent); @ViewChild(SplitComponent, {static: true, read: ElementRef}) splitElementRef!: ElementRef; @ViewChild('directiveForestSplitArea', {static: true, read: ElementRef}) directiveForestSplitArea!: ElementRef; - currentSelectedElement: IndexedNode | null = null; - forest!: DevToolsNode[]; - splitDirection: 'horizontal' | 'vertical' = 'horizontal'; - parents: FlatNode[] | null = null; - showHydrationNodeHighlights: boolean = false; + readonly currentSelectedElement = signal(null); + readonly forest = signal([]); + readonly splitDirection = signal<'horizontal' | 'vertical'>('horizontal'); + readonly parents = signal(null); + readonly showHydrationNodeHighlights = signal(false); - private _resizeObserver = new ResizeObserver((entries) => - this._ngZone.run(() => { - this.refreshHydrationNodeHighlightsIfNeeded(); - - const resizedEntry = entries[0]; - if (resizedEntry.target === this.splitElementRef.nativeElement) { - this.splitDirection = resizedEntry.contentRect.width <= 500 ? 'vertical' : 'horizontal'; - } - - if (!this.breadcrumbs) { - return; - } - - this.breadcrumbs.updateScrollButtonVisibility(); - }), - ); + private _resizeObserver!: ResizeObserver; private _clickedElement: IndexedNode | null = null; private _refreshRetryTimeout: null | ReturnType = null; - constructor( - private readonly _appOperations: ApplicationOperations, - private readonly _messageBus: MessageBus, - private readonly _propResolver: ElementPropertyResolver, - private readonly _cdr: ChangeDetectorRef, - private readonly _ngZone: NgZone, - private readonly _frameManager: FrameManager, - ) {} - - ngOnInit(): void { - this.subscribeToBackendEvents(); - this.refresh(); - this._resizeObserver.observe(this.splitElementRef.nativeElement); - this._resizeObserver.observe(this.directiveForestSplitArea.nativeElement); + private readonly _appOperations = inject(ApplicationOperations); + private readonly _messageBus = inject>(MessageBus); + private readonly _propResolver = inject(ElementPropertyResolver); + private readonly _frameManager = inject(FrameManager); + + constructor() { + afterNextRender(() => { + this._resizeObserver = new ResizeObserver((entries) => { + this.refreshHydrationNodeHighlightsIfNeeded(); + + const resizedEntry = entries[0]; + if (resizedEntry.target === this.splitElementRef.nativeElement) { + this.splitDirection.set( + resizedEntry.contentRect.width <= 500 ? 'vertical' : 'horizontal', + ); + } + }); + + this.subscribeToBackendEvents(); + this.refresh(); + this._resizeObserver.observe(this.splitElementRef.nativeElement); + this._resizeObserver.observe(this.directiveForestSplitArea.nativeElement); + }); } ngOnDestroy(): void { @@ -157,16 +150,17 @@ export class DirectiveExplorerComponent implements OnInit, OnDestroy { this._messageBus.emit('setSelectedComponent', [node.position]); this.refresh(); } else { - this._clickedElement = this.currentSelectedElement = null; + this._clickedElement = null; + this.currentSelectedElement.set(null); } } subscribeToBackendEvents(): void { this._messageBus.on('latestComponentExplorerView', (view: ComponentExplorerView) => { - this.forest = view.forest; - this.currentSelectedElement = this._clickedElement; - if (view.properties && this.currentSelectedElement) { - this._propResolver.setProperties(this.currentSelectedElement, view.properties); + this.forest.set(view.forest); + this.currentSelectedElement.set(this._clickedElement); + if (view.properties && this._clickedElement) { + this._propResolver.setProperties(this._clickedElement, view.properties); } }); @@ -192,12 +186,10 @@ export class DirectiveExplorerComponent implements OnInit, OnDestroy { viewSource(directiveName: string): void { // find the index of the directive with directiveName in this.currentSelectedElement.directives + const selectedEl = this.currentSelectedElement(); + if (!selectedEl) return; - if (!this.currentSelectedElement) { - return; - } - - const directiveIndex = this.currentSelectedElement.directives.findIndex( + const directiveIndex = selectedEl.directives.findIndex( (directive) => directive.name === directiveName, ); @@ -213,7 +205,7 @@ export class DirectiveExplorerComponent implements OnInit, OnDestroy { } this._appOperations.viewSource( - this.currentSelectedElement.position, + selectedEl.position, directiveIndex !== -1 ? directiveIndex : undefined, new URL(selectedFrame!.url), ); @@ -262,8 +254,8 @@ export class DirectiveExplorerComponent implements OnInit, OnDestroy { // set of properties which are already expanded. if ( !this._clickedElement || - !this.currentSelectedElement || - !sameDirectives(this._clickedElement, this.currentSelectedElement) + !this.currentSelectedElement() || + !sameDirectives(this._clickedElement, this.currentSelectedElement()!) ) { return { type: PropertyQueryTypes.All, @@ -284,12 +276,11 @@ export class DirectiveExplorerComponent implements OnInit, OnDestroy { } handleSelect(node: FlatNode): void { - this.directiveForest.handleSelect(node); + this.directiveForest()?.handleSelect(node); } handleSetParents(parents: FlatNode[] | null): void { - this.parents = parents; - this._cdr.detectChanges(); + this.parents.set(parents); } inspect({ @@ -324,7 +315,7 @@ export class DirectiveExplorerComponent implements OnInit, OnDestroy { } refreshHydrationNodeHighlightsIfNeeded() { - if (this.showHydrationNodeHighlights) { + if (this.showHydrationNodeHighlights()) { this.removeHydrationNodesHightlights(); this.hightlightHydrationNodes(); } diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.spec.ts b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.spec.ts index 9fbda282341c2..69ed06b125197 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.spec.ts +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-explorer.spec.ts @@ -19,7 +19,7 @@ import SpyObj = jasmine.SpyObj; import {By} from '@angular/platform-browser'; import {FrameManager} from '../../frame_manager'; import {TabUpdate} from '../tab-update'; -import {Component, EventEmitter, Input, Output, CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; +import {Component, CUSTOM_ELEMENTS_SCHEMA, output, input} from '@angular/core'; import {ElementPropertyResolver, FlatNode} from './property-resolver/element-property-resolver'; import {BreadcrumbsComponent} from './directive-forest/breadcrumbs/breadcrumbs.component'; import {PropertyTabComponent} from './property-tab/property-tab.component'; @@ -30,15 +30,15 @@ import {PropertyTabComponent} from './property-tab/property-tab.component'; standalone: true, }) class MockDirectiveForestComponent { - @Input() forest: IndexedNode[] = []; - @Input() currentSelectedElement: IndexedNode | null = null; - @Input() showCommentNodes = false; - @Output() selectNode = new EventEmitter(); - @Output() selectDomElement = new EventEmitter(); - @Output() setParents = new EventEmitter(); - @Output() highlightComponent = new EventEmitter(); - @Output() removeComponentHighlight = new EventEmitter(); - @Output() toggleInspector = new EventEmitter(); + readonly forest = input([]); + readonly currentSelectedElement = input(null); + readonly showCommentNodes = input(false); + readonly selectNode = output(); + readonly selectDomElement = output(); + readonly setParents = output(); + readonly highlightComponent = output(); + readonly removeComponentHighlight = output(); + readonly toggleInspector = output(); } @Component({ @@ -47,10 +47,10 @@ class MockDirectiveForestComponent { standalone: true, }) class MockBreadcrumbsComponent { - @Input() parents: IndexedNode[] = []; - @Output() handleSelect = new EventEmitter(); - @Output() mouseLeaveNode = new EventEmitter(); - @Output() mouseOverNode = new EventEmitter(); + readonly parents = input([]); + readonly handleSelect = output(); + readonly mouseLeaveNode = output(); + readonly mouseOverNode = output(); } @Component({ @@ -59,9 +59,9 @@ class MockBreadcrumbsComponent { standalone: true, }) class MockPropertyTabComponent { - @Input() currentSelectedElement: IndexedNode | null = null; - @Output() inspect = new EventEmitter<{node: FlatNode; directivePosition: DirectivePosition}>(); - @Output() viewSource = new EventEmitter(); + readonly currentSelectedElement = input(null); + readonly inspect = output<{node: FlatNode; directivePosition: DirectivePosition}>(); + readonly viewSource = output(); } describe('DirectiveExplorerComponent', () => { @@ -157,9 +157,9 @@ describe('DirectiveExplorerComponent', () => { ]); currentSelectedElement.position = [0]; currentSelectedElement.children = []; - comp.currentSelectedElement = currentSelectedElement; + comp.currentSelectedElement.set(currentSelectedElement); comp.refresh(); - expect(comp.currentSelectedElement).toBeTruthy(); + expect(comp.currentSelectedElement()).toBeTruthy(); expect(messageBusMock.emit).toHaveBeenCalledWith('getLatestComponentExplorerView', [ undefined, ]); @@ -199,12 +199,12 @@ describe('DirectiveExplorerComponent', () => { }); it('should show hydration slide toggle', () => { - comp.isHydrationEnabled = true; + fixture.componentRef.setInput('isHydrationEnabled', true); fixture.detectChanges(); const toggle = fixture.debugElement.query(By.css('mat-slide-toggle')); expect(toggle).toBeTruthy(); - comp.isHydrationEnabled = false; + fixture.componentRef.setInput('isHydrationEnabled', false); fixture.detectChanges(); const toggle2 = fixture.debugElement.query(By.css('mat-slide-toggle')); expect(toggle2).toBeFalsy(); @@ -215,11 +215,11 @@ describe('DirectiveExplorerComponent', () => { describe('view source', () => { it('should not call application operations view source if no frames are detected', () => { const directiveName = 'test'; - comp.currentSelectedElement = { + comp.currentSelectedElement.set({ directives: [{name: directiveName}], position: [0], children: [] as IndexedNode[], - } as IndexedNode; + } as IndexedNode); comp.viewSource(directiveName); expect(applicationOperationsSpy.viewSource).toHaveBeenCalledTimes(0); }); @@ -229,11 +229,11 @@ describe('DirectiveExplorerComponent', () => { contentScriptConnected(1, 'test2', 'http://localhost:4200/url'); const directiveName = 'test'; - comp.currentSelectedElement = { + comp.currentSelectedElement.set({ directives: [{name: directiveName}], position: [0], children: [] as IndexedNode[], - } as IndexedNode; + } as IndexedNode); comp.viewSource(directiveName); @@ -252,11 +252,11 @@ describe('DirectiveExplorerComponent', () => { contentScriptConnected(1, 'test2', 'http://localhost:4200/url2'); const directiveName = 'test'; - comp.currentSelectedElement = { + comp.currentSelectedElement.set({ directives: [{name: directiveName}], position: [0], children: [] as IndexedNode[], - } as IndexedNode; + } as IndexedNode); comp.viewSource(directiveName); diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest/breadcrumbs/breadcrumbs.component.html b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest/breadcrumbs/breadcrumbs.component.html index e0ca0503ffb6f..9888a1425f645 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest/breadcrumbs/breadcrumbs.component.html +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest/breadcrumbs/breadcrumbs.component.html @@ -1,9 +1,9 @@ - - @if ( - treeControl.isExpanded(node) && - node.hydration?.status === 'mismatched' && + treeControl.isExpanded(node) && + node.hydration?.status === 'mismatched' && (node.hydration.expectedNodeDetails || node.hydration.actualNodeDetails) ) {
diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest/directive-forest.component.ts b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest/directive-forest.component.ts index 81cd5a6c69765..fe41f660fe976 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest/directive-forest.component.ts +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest/directive-forest.component.ts @@ -14,14 +14,16 @@ import { import {FlatTreeControl} from '@angular/cdk/tree'; import { ChangeDetectionStrategy, - ChangeDetectorRef, Component, + computed, + effect, ElementRef, - EventEmitter, HostListener, - Input, - Output, - ViewChild, + inject, + input, + output, + signal, + viewChild, } from '@angular/core'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {DevToolsNode, ElementPosition, Events, MessageBus} from 'protocol'; @@ -51,31 +53,19 @@ import {MatTooltip} from '@angular/material/tooltip'; ], }) export class DirectiveForestComponent { - @Input() - set forest(forest: DevToolsNode[]) { - this._latestForest = forest; - const result = this._updateForest(forest); - const changed = - result.movedItems.length || result.newItems.length || result.removedItems.length; - if (this.currentSelectedElement && changed) { - this._reselectNodeOnUpdate(); - } - } - @Input({required: true}) currentSelectedElement!: IndexedNode; - @Input() - set showCommentNodes(show: boolean) { - this._showCommentNodes = show; - this.forest = this._latestForest; - } + readonly forest = input([]); + readonly showCommentNodes = input(false); + readonly currentSelectedElement = input.required(); - @Output() selectNode = new EventEmitter(); - @Output() selectDomElement = new EventEmitter(); - @Output() setParents = new EventEmitter(); - @Output() highlightComponent = new EventEmitter(); - @Output() removeComponentHighlight = new EventEmitter(); - @Output() toggleInspector = new EventEmitter(); + readonly selectNode = output(); + readonly selectDomElement = output(); + readonly setParents = output(); + readonly highlightComponent = output(); + readonly removeComponentHighlight = output(); + readonly toggleInspector = output(); - @ViewChild(CdkVirtualScrollViewport) viewport!: CdkVirtualScrollViewport; + readonly viewport = viewChild.required(CdkVirtualScrollViewport); + private readonly updateForestResult = computed(() => this._updateForest(this.forest())); filterRegex = new RegExp('.^'); currentlyMatchedIndex = -1; @@ -83,14 +73,7 @@ export class DirectiveForestComponent { selectedNode: FlatNode | null = null; parents!: FlatNode[]; - private _highlightIDinTreeFromElement: number | null = null; - private _showCommentNodes = false; - private _latestForest!: DevToolsNode[]; - - set highlightIDinTreeFromElement(id: number | null) { - this._highlightIDinTreeFromElement = id; - this._cdr.markForCheck(); - } + private readonly highlightIDinTreeFromElement = signal(null); readonly treeControl = new FlatTreeControl( (node) => node!.level, @@ -102,28 +85,37 @@ export class DirectiveForestComponent { private _initialized = false; private resizeObserver: ResizeObserver; - constructor( - private _tabUpdate: TabUpdate, - private _messageBus: MessageBus, - private _cdr: ChangeDetectorRef, - private elementRef: ElementRef, - ) { + private _tabUpdate = inject(TabUpdate); + private _messageBus = inject>(MessageBus); + private elementRef = inject(ElementRef); + + constructor() { this.subscribeToInspectorEvents(); this._tabUpdate.tabUpdate$.pipe(takeUntilDestroyed()).subscribe(() => { - if (this.viewport) { + if (this.viewport()) { setTimeout(() => { - this.viewport.scrollToIndex(0); - this.viewport.checkViewportSize(); + const viewport = this.viewport(); + viewport.scrollToIndex(0); + viewport.checkViewportSize(); }); } }); // In some cases there a height changes, we need to recalculate the viewport size. this.resizeObserver = new ResizeObserver(() => { - this.viewport.scrollToIndex(0); - this.viewport.checkViewportSize(); + this.viewport().scrollToIndex(0); + this.viewport().checkViewportSize(); }); this.resizeObserver.observe(this.elementRef.nativeElement); + + effect(() => { + const result = this.updateForestResult(); + const changed = + result.movedItems.length || result.newItems.length || result.removedItems.length; + if (this.currentSelectedElement() && changed) { + this._reselectNodeOnUpdate(); + } + }); } ngOnDestroy(): void { @@ -138,11 +130,11 @@ export class DirectiveForestComponent { }); this._messageBus.on('highlightComponent', (id: number) => { - this.highlightIDinTreeFromElement = id; + this.highlightIDinTreeFromElement.set(id); }); this._messageBus.on('removeComponentHighlight', () => { - this.highlightIDinTreeFromElement = null; + this.highlightIDinTreeFromElement.set(null); }); } @@ -167,7 +159,7 @@ export class DirectiveForestComponent { selectAndEnsureVisible(node: FlatNode): void { this.select(node); - const scrollParent = this.viewport.elementRef.nativeElement; + const scrollParent = this.viewport().elementRef.nativeElement; // The top most point we see an element const top = scrollParent.scrollTop; // That's the bottom most point we currently see an element. @@ -201,7 +193,7 @@ export class DirectiveForestComponent { private _reselectNodeOnUpdate(): void { const nodeThatStillExists = this.dataSource.getFlatNodeFromIndexedNode( - this.currentSelectedElement, + this.currentSelectedElement(), ); if (nodeThatStillExists) { this.select(nodeThatStillExists); @@ -215,7 +207,7 @@ export class DirectiveForestComponent { movedItems: FlatNode[]; removedItems: FlatNode[]; } { - const result = this.dataSource.update(forest, this._showCommentNodes); + const result = this.dataSource.update(forest, this.showCommentNodes()); if (!this._initialized && forest && forest.length) { this.treeControl.expandAll(); this._initialized = true; @@ -399,7 +391,7 @@ export class DirectiveForestComponent { } highlightNode(position: ElementPosition): void { - this._highlightIDinTreeFromElement = null; + this.highlightIDinTreeFromElement.set(null); this.highlightComponent.emit(position); } @@ -409,8 +401,8 @@ export class DirectiveForestComponent { isHighlighted(node: FlatNode): boolean { return ( - !!this._highlightIDinTreeFromElement && - this._highlightIDinTreeFromElement === node.original.component?.id + !!this.highlightIDinTreeFromElement() && + this.highlightIDinTreeFromElement() === node.original.component?.id ); } diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest/filter/filter.component.html b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest/filter/filter.component.html index 50585d253d2d5..974bebbd645b7 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest/filter/filter.component.html +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest/filter/filter.component.html @@ -12,7 +12,7 @@ placeholder="Search components" /> - @if (hasMatched) { + @if (hasMatched()) {