Skip to content

A little — every day.

Angular 6 → 22: the upgrade path and where you trip up

7 min read

On this page
In short
There’s only one way to upgrade Angular across many versions: one major version at a time, with a green build and tests after each. Big breaks are rare, but they’re predictable: a new rendering engine, a new build system, standalone, signals, zone.js. Below is a map of the path from 6 to 22, what broke for us on the last steps (import cycles, overlays on top of everything, zone.js), and why in a monorepo you do this once rather than ten times.

Rule number one: one version at a time

Angular releases a major version roughly every six months. The upgrade command:

npx ng update @angular/core@17 @angular/cli@17

ng update doesn’t just change the number in package.json. It runs migrations — scripts that rewrite your code for the new version. Migrations are written for going from N to N+1. Jump from 12 to 17, and the migrations for 13, 14, 15 and 16 won’t run, and you’ll have to sort it out by hand.

So the cycle is:

  1. Upgrade by one version.
  2. Build, run the tests, open the app.
  3. Commit it as a separate commit.
  4. Next version.

A separate commit per version means you can roll back exactly one step and use git bisect to find which one broke something.

For an Nx monorepo, instead of ng update there’s nx migrate: it upgrades Angular, Nx itself and the plugins consistently.

npx nx migrate 21          # пишет новые версии в package.json и migrations.json
pnpm install
npx nx migrate --run-migrations

And check against the official Angular Update Guide: you pick “from” and “to”, and it shows what to do by hand.

The map: where the big steps are

Most upgrades are “upgraded, fixed a couple of warnings, moved on”. But there are a few steps where something fundamental changes.

VersionWhat changesWhy it’s risky
6ng update, RxJS 6old RxJS imports (rxjs/add/operator/...) have to be rewritten to pipe()
8lazy loading via import()strings like './module#Module' in routes are deprecated
9Ivy engine by defaultold libraries built for View Engine; stricter template checks
12–13View Engine removed, IE11 no longer supportedlibraries without Ivy support stop working entirely
14–15standalone components, typed formsyou can keep the old way, but new code is written differently
16–17signals, new control flow (@if, @for), esbuild-based buildera huge but mechanical template refactor
19standalone: true by defaultcomponents with NgModule need an explicit standalone: false
20–22new builder, zone.js leaves the defaults, CDK changes overlaysdetails below

In practice the most painful steps are 9 and 13: the problem is usually not your code but dependencies nobody updated for Ivy. There’s one solution: replace the library or remove it.

How it went for us

Before the monorepo we had more than ten copies of the app, each upgraded separately — which in practice meant not upgraded. The move to a monorepo (details in nx-monorepo-from-copies) started on Angular 16. Then:

  • 16 → 17 — a couple of months after the start, in a separate commit.
  • 17 → 19 — a year and a half later. That was a mistake: the longer you stand still, the more piles up.
  • 19 → 20 → 21 → 22 — in one day, in three separate commits, together with Nx 20 → 23 and TypeScript 5.7 → 6.0.

The last jump was quick precisely because the code is shared: the migrations ran across all apps at once. But “in one day” is only the upgrade itself. Sorting out the consequences took another two weeks.

Step 20: the new builder

The old @angular-devkit/build-angular:browser builder (webpack-based) was replaced by @angular/build:application (esbuild-based). In each app’s project.json:

// было
"executor": "@angular-devkit/build-angular:browser",
"options": { "main": "src/main.ts", "buildOptimizer": true, "vendorChunk": true }

// стало
"executor": "@angular/build:application",
"options": { "browser": "src/main.ts" }

Some options simply disappeared (buildOptimizer, vendorChunk, namedChunks) — the new builder doesn’t need them. The output structure changed too: files now live in dist/<app>/browser/. If your deploy copies dist/<app>/, an extra folder ends up on the server. We caught that in CI.

The new builder is noticeably faster. But it’s stricter about module order — which leads to the next point.

Pitfall: import cycles

After the upgrade some pages started crashing at runtime with NG0919 (“Cannot read @Component metadata”). The build passed.

The cause is a circular import. An example:

// list.page.ts
import { EditDialog } from './edit.dialog';   // страница открывает диалог

// edit.dialog.ts
import { ListPage } from './list.page';        // диалог берёт тип у страницы

Modules in a cycle load in an unpredictable order. When Angular reads imports: [EditDialog], the EditDialog class may still be undefined. The old builder happened to load them in a “lucky” order. The new one doesn’t.

How to break the cycle, from best to worst:

  1. Move the shared part into a third file. Constants, tokens, interfaces go into *.constants.ts or *.interface.ts, which both depend on.
  2. Import only the type. If you only need the type, import type { ListPage } from './list.page'. TypeScript removes such an import at compile time, and there’s no cycle at runtime.
  3. Weaken the type. For example, MatDialogRef<any> instead of MatDialogRef<EditDialog>. It works, but loses type checking.

To be honest: in a few places we went with the third option to ship the upgrade faster. That’s debt, and it’s written down. The right one is the second.

And to keep cycles from appearing between libraries, you need strict boundaries: dependencies in one direction only, checked by the linter.

Pitfall: toasts under dialogs

After upgrading Angular CDK to 22, pop-up notifications (toasts) started appearing under modal windows.

The reason: CDK 22 by default renders overlays (dialogs, dropdowns, tooltips) through the Popover API — in the browser’s so-called top layer. The top layer is drawn above the whole document, and z-index has no effect on it. A toast with z-index: 1001 in the normal document ends up under a dialog in the top layer, however high you push the number.

We bring back the old behaviour with one provider:

import { OVERLAY_DEFAULT_CONFIG } from '@angular/cdk/overlay';

providers: [
  { provide: OVERLAY_DEFAULT_CONFIG, useValue: { usePopover: false } },
]

Lesson: read the changelog not only for Angular but also for CDK and Material. They’re upgraded together, but they change behaviour separately.

Step 21: zone.js and HttpClient

zone.js is a library that intercepts all asynchronous events (clicks, timers, HTTP) and tells Angular, “something happened, check the screen”. Angular is moving away from it towards signals, and new apps are created without zone.js.

For an existing app, the migration during the upgrade added an explicit provider so that nothing changed:

bootstrapApplication(AppComponent, {
  ...appConfig,
  providers: [provideZoneChangeDetection(), ...appConfig.providers],
});

That’s the right behaviour for a migration: an upgrade shouldn’t change behaviour unnoticed. Going zoneless is a separate task, with separate testing.

Likewise the migration added withXhr() to provideHttpClient(...) so the HTTP client stays on XMLHttpRequest. If you have file uploads with progress, check them after the upgrade.

After the upgrade: polishing

Upgrading the version isn’t the end. The new Angular offers new ways to write code, and it’s worth moving to them gradually, or in a couple of years you’ll have “old code” again:

  • inject() instead of constructor injection;
  • signals (signal, computed) for component state;
  • @if/@for instead of *ngIf/*ngFor;
  • takeUntilDestroyed() for subscriptions.

We did the move to inject() as a separate task: one commit per library. That keeps reviews small, and if something breaks, it’s clear where. For most of it there are official automatic migrations (ng generate @angular/core:inject and the like) — run them rather than rewriting by hand.

Checklist

  1. One major version at a time, a separate commit.
  2. ng update / nx migrate, not hand-editing package.json.
  3. After each: build, tests, a look at the app.
  4. Before starting — a list of dependencies: which ones don’t support the target version.
  5. The changelog for Angular and CDK/Material.
  6. New runtime errors (NG0919 and the like) — look for import cycles first.
  7. Don’t stay on one version for a year and a half: two upgrades a year are cheaper than one every three years.

Related posts: nx-monorepo-from-copies, tailwind-v3-to-v4-monorepo, ci-nx-affected.