# A worked upgrade: Angular 17 NgModule app → Angular 22

A realistic sequence for a medium app (≈120 components, NgModules, Karma, `@angular/animations`, Material). Adapt the version hops; the shape holds for any starting point. Verified 2026-09 against https://angular.dev/reference/releases and https://angular.dev/update-guide.

## Phase 0 — before touching anything (half a day)

1. Record the baseline: `npm test`, `ng build`, and the number of failing/skipped specs. An upgrade onto a red suite cannot be evaluated.
2. Inventory dependencies: `npm ls --depth=0`. For every package with `@angular` as a peer dependency, find the version that supports v22. Anything with no such release is a decision — replace, fork, or stop one major short — and it is cheaper now than in week three.
3. Read https://angular.dev/update-guide for 17 → 18, 18 → 19, 19 → 20, 20 → 21 and 21 → 22 with "Advanced" turned on. Note the manual steps; there are usually two or three per hop.
4. Agree the rule with the team: **trunk stays deployable after every step.** No long-lived branch.

## Phase 1 — the version hops (one PR each)

```bash
ng update @angular/core@18 @angular/cli@18 && npm test && ng build
ng update @angular/material@18                                  # same hop, not later
# commit, merge, deploy
ng update @angular/core@19 @angular/cli@19 && npm test && ng build
ng update @angular/core@20 @angular/cli@20 && npm test && ng build
ng update @angular/core@21 @angular/cli@21 && npm test && ng build
ng update @angular/core@22 @angular/cli@22 && npm test && ng build
```

Checkpoints worth expecting on the way:

| Hop | What usually bites |
|---|---|
| → 18 | Material 3 theming API; `@angular/material` theme files need rework |
| → 19 | `standalone: true` becomes the default — remove it from new code, and expect lint noise |
| → 20 | `afterRender` renamed to `afterEveryRender`; the `AfterRenderPhase` enum removed; CLI starts generating suffix-less file names |
| → 21 | Zoneless becomes the default for new apps and Vitest the default runner — existing config is untouched, but the defaults you read about change |
| → 22 | `@angular/animations` deprecation surfaces; `withIncrementalHydration()`/`withEventReplay()` become redundant; `ChangeDetectionStrategy.Default` deprecated in favour of `Eager` |

Do **not** start modernising between hops. Mixing an `ng update` migration with a hand rewrite in one PR makes both unreviewable.

## Phase 2 — standalone (three PRs)

```bash
ng generate @angular/core:standalone     # choose mode 1: convert to standalone
npm test && ng build                     # commit
ng generate @angular/core:standalone     # mode 2: remove unnecessary NgModules
npm test && ng build                     # commit
ng generate @angular/core:standalone     # mode 3: bootstrap with standalone APIs
npm test && ng build                     # commit
```

Run the modes in order and verify between each. Mode 2 will leave NgModules that genuinely still provide something — that is correct, not a failure; convert those by hand later or leave them.

The one hand step after mode 3: `main.ts` now calls `bootstrapApplication(App, appConfig)`, and every `forRoot()` provider from the old `AppModule` has to be a `provide*()` function in `app.config.ts`. `importProvidersFrom(SomeModule)` is the escape hatch for a library that still only ships an NgModule.

## Phase 3 — template and DI modernisation (one PR each)

```bash
ng generate @angular/core:control-flow
ng generate @angular/core:inject
ng generate @angular/core:signal-input-migration
ng generate @angular/core:output-migration
ng generate @angular/core:signal-queries-migration
ng generate @angular/core:common-to-standalone
ng generate @angular/core:ngclass-to-class
ng generate @angular/core:ngstyle-to-style
ng generate @angular/core:cleanup-unused-imports
ng generate @angular/core:self-closing-tag
ng generate @angular/core:route-lazy-loading
```

For a repo where one of these touches more than ~300 files, split by feature:

```bash
ng generate @angular/core:control-flow --path src/app/features/orders
```

Review each diff. The two things schematics cannot decide for you: whether a converted `@for` should have gained an `@empty` branch, and whether a component's remaining `imports` are the minimal set.

## Phase 4 — test suite (one PR, or two)

Karma → Vitest as described in `manual-migrations.md`. Do this **before** phase 5: removing `zone.js` breaks every `fakeAsync` test, and you want that breakage on a suite you have already converted.

## Phase 5 — zoneless (one PR, plus a click-through)

Remove `zone.js` from `polyfills`, confirm no `provideZoneChangeDetection()` remains, then click through every feature by hand. This is the one step a green unit suite does not validate, because the specs never exercised zone patching in the first place.

Budget a day for the fallout in a medium app, concentrated in: third-party widget callbacks, `addEventListener` outside Angular, and anything that used to update "somehow" after a promise.

## Phase 6 — the optional rest

- `@angular/animations` → `animate.enter`/`animate.leave` (one PR per animated feature; there is no rush, but the package will not be there forever).
- `.component.ts` → suffix-less names, if the team wants it: one mechanical PR, no behaviour change, landed on a quiet day.
- New forms in Signal Forms. Existing Reactive Forms stay.

## Estimating

For a medium app with a green suite, on a team that keeps trunk deployable:

| Phase | Typical |
|---|---|
| 0 inventory and reading | 0.5 day |
| 1 five version hops | 1–2 days, mostly waiting on CI and fixing Material theming |
| 2 standalone | 1 day |
| 3 template/DI schematics | 1–2 days, almost all of it reviewing diffs |
| 4 Karma → Vitest | 1–3 days, depending on how much `fakeAsync` is in the suite |
| 5 zoneless | 1 day plus the click-through |
| 6 optional | ongoing |

The number that blows up an estimate is never the schematics. It is a dependency with no v22 release, or a suite that was already red. Both are visible in phase 0 — which is why phase 0 is not optional.

## Rollback

Every phase is one merged PR, so rollback is `git revert` of that PR plus a redeploy. That property is the entire reason for the one-PR-per-step rule; it is worth more than the time it appears to cost.
