ここ最近の Angular リリースで、変更検知の改善とシンプルなシンタックスを提供する SignalsControl flow が導入されました。早く使ってみたくなりますね。とはいえ手元にはそれ以前のバージョンのプロジェクトが多く、ようやく最近そのひとつをv17にアップデートしたところです。

当記事ではアップデート作業の順を追いながら詰まったところなどを紹介します。サードパーティのパッケージの作業も含みますが、作業の全体像の参考になればと思います。

Before & After

アップデートによる主なバージョン変更は以下のとおりです。

パッケージ Before After
@angular/core v15.2 v17.3
@angular/cdk v14.2 v17.3
typescript v4.8 v5.4
msw v1.3 v2.2
@testing-library/angular v14.3 v15.2

マイグレーションガイドをチェック

最初にマイグレーションガイドで内容をチェックします。

https://update.angular.io/?locale=ja-JP&v=15.0-17.0

ガイドを見ると Node.jsTypeScript のバージョン、手作業で必要なマイグレーションが分かります。事前にさらっと読んで準備できるところはリファクタリングしておくとアップデート作業が楽になります。

ng update コマンドを実行

Angular 関連パッケージのアップデートをチェックします。

$ ng update

  We analyzed your package.json, there are some packages to update:
  
    Name                         Version             Command to update
    ---------------------------------------------------------------------
    @angular-eslint/schematics   15.2.1 -> 17.3.0    ng update @angular-eslint/schematics
    @angular/cdk                 14.2.7 -> 15.2.9    ng update @angular/cdk@15
    @angular/cli                 15.2.10 -> 16.2.9   ng update @angular/cli@16

表示されたパッケージをアップデートします。

$ ng update \
  @angular-eslint/schematics \
  @angular/cdk@15 \
  @angular/cli@16

エラーが出力されました。依存にひっかかるパッケージがあるようです。🙄

Collecting installed dependencies...
Fetching dependency metadata from registry...

Package "@testing-library/angular" has an incompatible peer dependency to "@angular/common" (requires ">= 17.0.0", would install "16.2.12")

✖ Migration failed: Incompatible peer dependencies found.

このようなパッケージはアップデート後の Angular と共存させるために、適切なバージョンにアップデートする必要があります。

プロジェクトが依存するパッケージが多くなると依存関係が複雑になり、一度に依存解決できないことがあります。そんな時は --force オプションで主要なものを先に強制アップデートし、すんなり npm install できるようになるまでバージョンの調整を繰り返しています。

コードの差分をチェック

ng update コマンドの実行が終わったらコードに加えられた更新内容をチェックします。今回は polyfills.ts の読み込みが廃止されたようです。

// angular.json

"projects": {
  "app": {
    "architect": {
      "build": {
        "options": {
-          "polyfills": "src/polyfills.ts",
+          "polyfills": [ "zone.js" ], 

polyfills.ts にはオリジナルのコードを加えておらずプロジェクト内からの参照箇所もなかったので、ファイルを削除しました。

マイグレーションコマンドを実行

今回のアップデートは、スタンドアロンコンポーネントへの移行が大きなモチベーションでした。

600個を超えるコンポーネントとモジュールの参照関係が部分的な移行を難しくしていたため、コマンドを使って一括でコンポーネント群を書き換えました。

$ ng generate @angular/core:standalone

シンタックス変更をともなうマイグレーションは、このように @angular/core のスキーマティクスが用意されているものがあります。アップデート作業と同時でもよし、別作業でもよし、任意のタイミングで実行できるのが嬉しいですね。

Lintを実行

スタンドアロンコンポーネントへの一括変換コマンドはLintを実行しません。git コマンドのフックやCIでLintを走らせている場合、そのタイミングで大量の差分が発生します。手動でLintを走らせて差分だけでコミットまたはプルリクエストすると、後から調整を入れたコードが見やすくなるのでそうしています。

$ ng lint --fix

@deprecated に対応

Lintを走らせた時に、廃止予定の機能が使用されていないかチェックします(検出するかどうかはLintの設定によります)。

今回は ComponentFactoryResolver@deprecated でマークされるようになりました。

// Before
import { ComponentFactoryResolver, ApplicationRef } from "@angular/core";

export class PopupMessageService {
  private readonly resolver: inject(ComponentFactoryResolver); // 🙄 @deprecated
  private readonly appRef: inject(ApplicationRef);

  attach() {
    const factory = this.resolver.resolveComponentFactory(PopupMessageComponent);
    const ref = factory.create(this.injector);

    document.body.appendChild(ref.location.nativeElement);
    this.appRef.attachView(ref.hostView);
  }

エディターでサジェストされた新しいシンタックスは ViewContainerRef.createComponent ですが、使用箇所はポップアップ表示サービスでコンテナーを所有していません。代替として @angular/cdkOverlay を使ったコードに書き換えました。

// After
import { Overlay, OverlayRef } from "@angular/cdk/overlay";
import { ComponentPortal } from "@angular/cdk/portal";

export class PopupMessageService {
  private readonly overlay = inject(Overlay); // 😊

  attach() {
    const ref = this.overlay.create();
    const component = new ComponentPortal(PopupMessageComponent);

    ref.attach(component);
  }
}

サードパーティパッケージのアップデート

今回アップデートしたプロジェクトでは、HTTP通信をモックする msw を使用しています。 TypeScript のバージョンアップにより1系から2系へのアップデートが必須で、まあまあ大きな破壊的変更が含まれました。

https://mswjs.io/docs/migrations/1.x-to-2.x

破壊的変更のひとつは、モック対象が XMLHttpRequest から Fetch API に変わったことです。この変更はアプリケーションの HTTP リクエストにも影響します。

XMLHttpRequest にこだわる理由はなかったのでアプリケーション側で HTTP リクエストを変更することにしました。Angular では withFetch() オプションを指定するだけで XMLHttpRequest から Fetch API にサクッと切り替えできます。

// main.ts
+ import { provideHttpClient, withFetch } from "@angular/common/http";

bootstrapApplication(AppComponent, {
  providers: [
+    provideHttpClient(withFetch()),
    ...

もうひとつの破壊的変更は、コールバック方式が廃止され msw のシンタックスが大きく変わったことです。エディターの置換機能など使いつつ、すべてのモックハンドラーを書き換えました。

// Before
import { rest } from "msw";

rest.get("/resource", (req, res, ctx) => {
  return res(
    ctx.status(200),
    ctx.delay(1000),
    ctx.json({ message: "Hello" })
  );
})
// After
import { delay, http, HttpResponse } from "msw";

http.get("/resource", async ({ request, params }) => {
  await delay(1000);

  return new HttpResponse({ message: "Hello" }, { status: 200 });
});

テストを実行

ng lintng serve がエラーや警告を検出しなくなったら、テストを実行します。

$ ng test

# ● TestComponent › Should be rendered.
#   Unexpected "ChildComponent" found in the "declarations" array of the "TestBed.configureTestingModule" call,
#   "ChildComponent" is marked as standalone and can't be declared in any NgModule - 
#   did you intend to import it instead (by adding it to the "imports" array)?
# 
#       6 | describe("TestComponent", () => {
#   >  7 |     await render(TestComponent, {
#       8 |       declarations: [ChildComponent],

#     at node_modules/@angular/core/fesm2022/testing.mjs:761:23
#         at Array.forEach (<anonymous>)
#     at assertNoStandaloneComponents (node_modules/@angular/core/fesm2022/testing.mjs:757:11)
#     at TestBedCompiler.configureTestingModule (node_modules/@angular/core/fesm2022/testing.mjs:832:13)
#     at _TestBedImpl.configureTestingModule (node_modules/@angular/core/fesm2022/testing.mjs:1899:23)

今回はアップデート作業と同時にスタンドアロンコンポーネントにマイグレーションしたことが影響して、大量にテストが落ちました。

テストコードのスタンドアロンコンポーネント対応

スタンドアロンコンポーネントへの一括変換コマンドの実行後、 テストコードは何も更新されていませんでした。これらのテストは手作業で書き換えていきました。

テスト対象がスタンドアロンコンポーネントなら、依存をひとつひとつ指定する必要はありません。ずらりと並んだコンポーネントやモジュール群の importsdeclarations を一気に削除できるのは気持ちが良いですね。

// Before
import { render } from "@testing-library/angular";
import { MockComponent, MockPipe } from "ng-mocks";
import { CommonModule } from "@angular/common";
import { FormsModule } from "@angular/forms";

test("Should be rendered", async () => {
  return await render(TestComponent, {
    declarations: [ // 🙄 依存が多い
      MockPipe(FooPipe),
      MockComponent(FooComponent),
    ],
    imports: [ // 🙄 依存が多い
      CommonModule,
      FormsModule,
    ],
  });
}
// After
import { render } from "@testing-library/angular";

test("Should be rendered", async () => {
  return await render(TestComponent, {
    imports: [],  // 😊
    declarations: [],  // 😊
  });
});

*ngIf*ngFor を使用しているコンポーネントは一括変換コマンドによって NgIf NgFor のモジュール参照が自動的に追加されます。テスト用のフェイクコンポーネントは更新されなかったので手作業で書き換えました。

// After
import { render } from "@testing-library/angular";
import { NgIf, NgFor } from "@angular/common";

test("Should be rendered", async () => {
  return await render(FakeComponent);
}

@Component({
  standalone: true, // 👈
  imports: [NgIf, NgFor], // 👈
  template: `
    <p *ngIf="...">
    <p *ngFor="...">
  `
})
class FakeComponent {}

テストコードの @deprecated 対応

Router に依存するコンポーネントのテストで RouterTestingModule@deprecated でマークされるようになりました。

// Before
import { RouterTestingModule } from "@angular/router/testing";
import { render } from "@testing-library/angular";

test("Should be rendered", async () => {
  return await render(TestComponent, {
    imports: [
      RouterTestingModule, // 🙄 @deprecated
    ],
  });
}

単純な依存解決なら provideRouter を、ページ遷移をテストするなら RouterTestingHarness を使うのが良さそうです。

// After
import { provideRouter } from "@angular/router";
import { render } from "@testing-library/angular";

test("Should be rendered", async () => {
  return await render(TestComponent, {
    providers: [
      provideRouter([]), // 😊
    ],
  });
}

テストコードのパッケージアップデート対応

今回アップデートしたプロジェクトでは、ツールチップ表示に helipopper というパッケージを使用しています。このパッケージは Tippy.js に依存していて、テスト実行時に TypeError: (0 , import_tippy.default) is not a function エラーを吐くようになってしまいました。

// Before
import { render } from "@testing-library/angular";
import { MockDirective } from "ng-mocks";
import { TippyDirective } from "@ngneat/helipopper";

test("Should be rendered", async () => {
  return await render(TestComponent, {
    declarations: [
      MockDirective(TippyDirective),
    ],
  });
}

// TypeError: (0 , import_tippy.default) is not a function
//   at node_modules/@ngneat/helipopper/fesm2020/ngneat-helipopper.mjs:240:29
//   at _ZoneDelegate.Object.<anonymous>._ZoneDelegate.invoke (node_modules/zone.js/bundles/zone.umd.js:411:30)

Angular Testing Libraryrender()componentImports キーを指定すると、スタンドアロンコンポーネントの依存をすべて外から指定できます。これを使って Tippy モジュールをモックで差し替えることで対応しました。

// After
import { render } from "@testing-library/angular";
import { MockModule } from "ng-mocks";
import { TippyModule } from "@ngneat/helipopper";

test("Should be rendered", async () => {
  return await render(TestComponent, {
    componentImports: [ // 😊
      MockModule(TippyModule),
      ChildComponent1,
      ChildComponent2,
    ],
  });
}

気持ちよく削除したテストコードのモジュール参照を componentImports 配下に戻すことになりますが、他の解決策が見つからずワークアラウンドでテストを通すことを優先しました。そもそものエラーメッセージについては GitHub Discussion で議論されているため、新しいバージョンでの解決を期待しています。

https://github.com/ngneat/helipopper/discussions/150

ビルドを実行

Angular アップデートでビルド時間とファイルサイズがどのように変わるか、気になるところですよね。そこでアップデート前後のプロダクションビルドを比較してみました。

# Before(v15)
$ ng build

Initial Chunk Files | Names            |  Raw Size | Estimated Transfer Size
main.js             | main             |   1.40 MB |               306.19 kB
styles.css          | styles           | 106.96 kB |                11.30 kB
polyfills.js        | polyfills        |  36.30 kB |                11.33 kB
runtime.js          | runtime          |   2.68 kB |                 1.25 kB
                    | Initial Total    |   1.54 MB |               330.06 kB

Lazy Chunk Files    | Names            |  Raw Size | Estimated Transfer Size
965.js              | foo-module       | 157.42 kB |                34.50 kB
7.js                | bar-module       |  86.10 kB |                15.88 kB

Build at: 0000-00-00T00:00:00.000Z - Hash: 1234abcd - Time: 17984ms # 🙄
# After(v17)
$ ng build

Initial chunk files | Names            |  Raw size | Estimated transfer size
main.js             | main             |   1.41 MB |               309.82 kB
styles.css          | styles           | 106.80 kB |                11.25 kB
polyfills.js        | polyfills        |  33.36 kB |                10.74 kB
runtime.js          | runtime          |   2.68 kB |                 1.24 kB
                    | Initial total    |   1.55 MB |               333.04 kB

Lazy chunk files    | Names            |  Raw size | Estimated transfer size
315.js              | foo-module       | 157.63 kB |                34.50 kB
44.js               | bar-module       |  89.67 kB |                16.60 kB

Build at: 0000-00-00T00:00:00.000Z - Hash: 5678abcd - Time: 8295ms # 😊

ファイルサイズはほとんど変わらず(むしろ微増)していますが、ビルド時間は半分以下に短縮されました。ng serve も同じ結果で、ファイルサイズはほとんど変わらずビルド時間は半分以下でした。開発環境がサッと立ち上がるので、これは嬉しいですね。

おわりに

他にも TypeScript のアップデートによる型の書き換えや、サードパーティパッケージのインターフェイス変更などがあり、今回のアップデート作業はそこそこ時間がかかるものでした。

ひととおりのコンパイルエラーに対応しテストがパスするようになった後、アプリケーションコードは(@deprecated を除き)何も変更せずにすんなり動いたので「え?マイグレーションの変換ミスとかないの?ほんとうに?」と思いました。

Angular のアップデート作業でしんどいのは、付随してサードパーティのパッケージがバージョンアップすることで起こる、依存問題やコンパイルエラーだと思っています。TypeScript のバージョンアップにひっぱられた問題が多いので、フロントエンドフレームワークのアップデートあるあるかなぁと思います。新しいバージョンに追随しないまま時間が経過し、やむなく依存を削除して自作コードに書き換えたパッケージも過去いくつかありました。

できるだけシームレスにアップデートできるよう気軽にパッケージ追加せず、そのパッケージが本当に必要なのか、信頼できる開発元なのかを自問していきたいと思います。

ようやくv17の波に乗り SignalsControl flow を使うのが楽しみになりました!