From e0fe00a73212236276f62b3e53f2bc263e05532a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A6=8A=E5=8E=9F=E6=98=8C=E5=BD=A6?= Date: Sun, 8 Jun 2025 10:42:13 +0900 Subject: [PATCH] feat(): support angular standalone default --- .../0002-import-standalone-component.test.ts | 4 +- .../0002-import-standalone-component.ts | 16 ++-- .../angular/migrations/standalone/index.ts | 2 +- .../src/angular/utils/angular-utils.test.ts | 79 +++++++++++++++++-- .../cli/src/angular/utils/angular-utils.ts | 36 +++++++-- 5 files changed, 115 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/angular/migrations/standalone/0002-import-standalone-component.test.ts b/packages/cli/src/angular/migrations/standalone/0002-import-standalone-component.test.ts index 0f6a75c..c92139e 100644 --- a/packages/cli/src/angular/migrations/standalone/0002-import-standalone-component.test.ts +++ b/packages/cli/src/angular/migrations/standalone/0002-import-standalone-component.test.ts @@ -257,7 +257,7 @@ describe("migrateComponents", () => { ); }); - it("should detect Ionic components within *ngIf expressions", () => { + it("should detect Ionic components within *ngIf expressions", async () => { const project = new Project({ useInMemoryFileSystem: true }); const component = ` @@ -297,7 +297,7 @@ describe("migrateComponents", () => { dedent(component), ); - migrateComponents(project, { dryRun: false }); + await migrateComponents(project, { dryRun: false }); expect(dedent(componentSourceFile.getText())).toBe( dedent(` diff --git a/packages/cli/src/angular/migrations/standalone/0002-import-standalone-component.ts b/packages/cli/src/angular/migrations/standalone/0002-import-standalone-component.ts index 0316123..96fd9a6 100644 --- a/packages/cli/src/angular/migrations/standalone/0002-import-standalone-component.ts +++ b/packages/cli/src/angular/migrations/standalone/0002-import-standalone-component.ts @@ -34,6 +34,7 @@ import { saveFileChanges } from "../../utils/log-utils"; export const migrateComponents = async ( project: Project, cliOptions: CliOptions, + dir: string ) => { for (const sourceFile of project.getSourceFiles()) { if (sourceFile.getFilePath().endsWith(".html")) { @@ -60,6 +61,7 @@ export const migrateComponents = async ( hasRouterLink, hasRouterLinkWithHref, cliOptions, + dir, ); await saveFileChanges(tsSourceFile, cliOptions); @@ -87,6 +89,7 @@ export const migrateComponents = async ( hasRouterLink, hasRouterLinkWithHref, cliOptions, + dir, ); if (ionicComponents.length > 0 || ionIcons.length > 0) { @@ -105,11 +108,12 @@ async function migrateAngularComponentClass( hasRouterLink: boolean, hasRouterLinkWithHref: boolean, cliOptions: CliOptions, + dir: string ) { let ngModuleSourceFile: SourceFile | undefined; let modifiedNgModule = false; - if (!isAngularComponentStandalone(sourceFile)) { + if (!await isAngularComponentStandalone(sourceFile, dir)) { ngModuleSourceFile = findNgModuleClassForComponent(sourceFile); } @@ -131,7 +135,7 @@ async function migrateAngularComponentClass( if (hasRouterLink) { addImportToClass(sourceFile, "IonRouterLink", "@ionic/angular/standalone"); - addImportToComponentDecorator(sourceFile, "IonRouterLink"); + await addImportToComponentDecorator(sourceFile, "IonRouterLink", dir); } if (hasRouterLinkWithHref) { @@ -140,14 +144,14 @@ async function migrateAngularComponentClass( "IonRouterLinkWithHref", "@ionic/angular/standalone", ); - addImportToComponentDecorator(sourceFile, "IonRouterLinkWithHref"); + await addImportToComponentDecorator(sourceFile, "IonRouterLinkWithHref", dir); } for (const ionicComponent of ionicComponents) { - if (isAngularComponentStandalone(sourceFile)) { + if (await isAngularComponentStandalone(sourceFile, dir)) { const componentClassName = kebabCaseToPascalCase(ionicComponent); - addImportToComponentDecorator(sourceFile, componentClassName); - removeImportFromComponentDecorator(sourceFile, "IonicModule"); + await addImportToComponentDecorator(sourceFile, componentClassName, dir); + await removeImportFromComponentDecorator(sourceFile, "IonicModule", dir); removeImportFromClass(sourceFile, "IonicModule", "@ionic/angular"); addImportToClass( sourceFile, diff --git a/packages/cli/src/angular/migrations/standalone/index.ts b/packages/cli/src/angular/migrations/standalone/index.ts index 1bc095f..7262067 100644 --- a/packages/cli/src/angular/migrations/standalone/index.ts +++ b/packages/cli/src/angular/migrations/standalone/index.ts @@ -47,7 +47,7 @@ export const runStandaloneMigration = async ({ // Migrate standalone projects using bootstrapApplication await migrateBootstrapApplication(project, cliOptions); // Migrate components using Ionic components - await migrateComponents(project, cliOptions); + await migrateComponents(project, cliOptions, dir); // Migrate import statements to @ionic/angular/standalone await migrateImportStatements(project, cliOptions); // Migrate the assets array in angular.json diff --git a/packages/cli/src/angular/utils/angular-utils.test.ts b/packages/cli/src/angular/utils/angular-utils.test.ts index 72736dc..27b2d30 100644 --- a/packages/cli/src/angular/utils/angular-utils.test.ts +++ b/packages/cli/src/angular/utils/angular-utils.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { getAngularNgModuleDecorator, @@ -7,9 +7,14 @@ import { isAngularComponentStandalone, } from "./angular-utils"; import { Project } from "ts-morph"; +import { getActualPackageVersion } from "./package-utils"; import { dedent } from "ts-dedent"; +vi.mock("./package-utils", () => ({ + getActualPackageVersion: vi.fn(), +})); + describe("getAngularNgModuleDecorator", () => { it("should return the NgModule decorator", () => { const sourceFileContent = ` @@ -115,7 +120,7 @@ describe("isAngularComponentClass", () => { }); describe("isAngularComponentStandalone", () => { - it("should return true if the component has standalone: true", () => { + it("should return true if the component has standalone: true", async () => { const sourceFileContent = ` import { Component } from '@angular/core'; @@ -130,10 +135,10 @@ describe("isAngularComponentStandalone", () => { const project = new Project({ useInMemoryFileSystem: true }); const sourceFile = project.createSourceFile("foo.ts", sourceFileContent); - expect(isAngularComponentStandalone(sourceFile)).toBe(true); + expect(await isAngularComponentStandalone(sourceFile)).toBe(true); }); - it("should return false if the component has standalone: false", () => { + it("should return false if the component has standalone: false", async () => { const sourceFileContent = ` import { Component } from '@angular/core'; @@ -148,10 +153,10 @@ describe("isAngularComponentStandalone", () => { const project = new Project({ useInMemoryFileSystem: true }); const sourceFile = project.createSourceFile("foo.ts", sourceFileContent); - expect(isAngularComponentStandalone(sourceFile)).toBe(false); + expect(await isAngularComponentStandalone(sourceFile)).toBe(false); }); - it("should return false if the component does not have the standalone flag", () => { + it("should return false if the component does not have the standalone flag", async () => { const sourceFileContent = ` import { Component } from '@angular/core'; @@ -165,6 +170,66 @@ describe("isAngularComponentStandalone", () => { const project = new Project({ useInMemoryFileSystem: true }); const sourceFile = project.createSourceFile("foo.ts", sourceFileContent); - expect(isAngularComponentStandalone(sourceFile)).toBe(false); + expect(await isAngularComponentStandalone(sourceFile)).toBe(false); + }); + + describe("with Angular version check", () => { + it("should return true for Angular 19+ even without standalone flag", async () => { + const sourceFileContent = ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'my-component', + template: '' + }) + export class MyComponent {} + `; + + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile("foo.ts", sourceFileContent); + + vi.mocked(getActualPackageVersion).mockResolvedValue("19.0.0"); + + expect(await isAngularComponentStandalone(sourceFile, "/test/dir")).toBe(true); + }); + + it("should return false for Angular 18 even without standalone flag", async () => { + const sourceFileContent = ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'my-component', + template: '' + }) + export class MyComponent {} + `; + + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile("foo.ts", sourceFileContent); + + vi.mocked(getActualPackageVersion).mockResolvedValue("18.0.0"); + + expect(await isAngularComponentStandalone(sourceFile, "/test/dir")).toBe(false); + }); + + it("should check standalone flag when Angular version cannot be determined", async () => { + const sourceFileContent = ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'my-component', + template: '', + standalone: true + }) + export class MyComponent {} + `; + + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile("foo.ts", sourceFileContent); + + vi.mocked(getActualPackageVersion).mockResolvedValue(null); + + expect(await isAngularComponentStandalone(sourceFile, "/test/dir")).toBe(true); + }); }); }); diff --git a/packages/cli/src/angular/utils/angular-utils.ts b/packages/cli/src/angular/utils/angular-utils.ts index bbe6bb0..d087db8 100644 --- a/packages/cli/src/angular/utils/angular-utils.ts +++ b/packages/cli/src/angular/utils/angular-utils.ts @@ -4,6 +4,7 @@ import { getDecoratorArgument, insertIntoDecoratorArgArray, } from "./decorator-utils"; +import { getActualPackageVersion } from "./package-utils"; /** * Finds the NgModule class that declares a given component. @@ -103,12 +104,14 @@ export function findComponentTypescriptFileForTemplateFile( * Adds a new import to the imports array in the Component decorator. * @param sourceFile The source file to add the import to. * @param importName The name of the import to add. + * @param dir The directory of the project. */ -export function addImportToComponentDecorator( +export async function addImportToComponentDecorator( sourceFile: SourceFile, importName: string, + dir: string, ) { - if (!isAngularComponentStandalone(sourceFile)) { + if (!(await isAngularComponentStandalone(sourceFile, dir))) { console.warn( "[Ionic Dev] Cannot add import to component decorator. Component is not standalone.", ); @@ -126,12 +129,14 @@ export function addImportToComponentDecorator( * Removes an import from the imports array in the Component decorator. * @param sourceFile The source file to remove the import from. * @param importName The name of the import to remove. + * @param dir The directory of the project. */ -export function removeImportFromComponentDecorator( +export async function removeImportFromComponentDecorator( sourceFile: SourceFile, importName: string, + dir: string, ) { - if (!isAngularComponentStandalone(sourceFile)) { + if (!(await isAngularComponentStandalone(sourceFile, dir))) { console.warn( "[Ionic Dev] Cannot remove import from component decorator. Component is not standalone.", ); @@ -183,8 +188,10 @@ export const removeImportFromNgModuleDecorator = ( * Checks if the source file is an Angular component using * the standalone: true option in the @Component decorator. * @param sourceFile The source file to check. + * @param dir The directory of the project. + * Since many tests do not specify dir, we have prepared dir = undefined. */ -export function isAngularComponentStandalone(sourceFile: SourceFile) { +export async function isAngularComponentStandalone(sourceFile: SourceFile, dir: string | undefined = undefined): Promise { if (!isAngularComponentClass(sourceFile)) { return false; } @@ -194,12 +201,29 @@ export function isAngularComponentStandalone(sourceFile: SourceFile) { return false; } + const standaloneDefault = await (async () => { + if (dir) { + const angularCoreVersion = await getActualPackageVersion( + dir, + "@angular/core" + ); + + if (angularCoreVersion) { + const [major] = angularCoreVersion.split("."); + if (parseInt(major) >= 19) { + return true; + } + } + } + return false; + })(); + const standalonePropertyAssignment = getDecoratorArgument( componentDecorator, "standalone", ); if (!standalonePropertyAssignment) { - return false; + return standaloneDefault; } const standalonePropertyValue =