Angular aktualisieren & migrieren - was man wissen muss

Zielgruppe: Entwickler:in, die einen Angular-Major-Sprung durchführen (oder verstehen) will. Dieses Dokument erklärt warum ein Update sinnvoll ist, wie Angulars Release- und Support-Modell funktioniert, die Regeln einer sicheren Migration - und dokumentiert die konkret in diesem Projekt durchgeführten Sprünge Angular 18 → 20 und 20 → 22 Schritt für Schritt, inkl. aller aufgetretenen Stolpersteine.

TL;DR - aktueller Stand

18.2 (Start) 20.3 (Etappe 1) 22.1 (jetzt, latest)
Angular / cli / build-angular 18.2 20.3 22.1
@angular/material + cdk 18.2 20.2 22.1
@ngrx/* 18.1 20.1 22.0
angular-oauth2-oidc 18.0 19.0 22.0
zone.js 0.14 0.15 0.16
TypeScript 5.5 5.9 6.0
Node (Projektmaschine) 24.18 - von Angular 18 als „Unsupported" markiert 24.18 - ab CLI 20 offiziell unterstützt 24.18 - im v22-engines-Range (^24.15.0)

Der Sprung wurde in zwei Vorhaben gemacht - jeweils majorweise mit Build + Unit-Tests + E2E nach jedem Schritt:

  1. 18 → 20 (Branch chore/angular-20-upgrade, nach master gemerged): 18→19, 19→20. Bewusster Zwischenhalt, damit v20 erst im Feld „ruht".
  2. 20 → 22 (Branch chore/angular-22-upgrade): 20→21, 21→22. Jetzt auf der jeweils neuesten stabilen Version.

Warum überhaupt updaten?

Ein Update ist kein Selbstzweck. Die Gründe, in absteigender Härte:

  1. Support-Fenster. Angular pflegt jeden Major nur ~18 Monate (6 Monate aktiv + 12 Monate LTS mit nur noch kritischen Fixes). Danach: keine Sicherheits-Patches mehr. Angular 18 (Mai 2024) läuft Ende 2025 aus dem LTS. Wer auf einer nicht mehr unterstützten Version bleibt, trägt jede künftig entdeckte Lücke selbst.
  2. Node-Kompatibilität - hier konkret spürbar. ng version auf dem alten Stand meldete Node: 24.18.0 (Unsupported). Angular 18 unterstützt offiziell nur Node 18/20/22. Die Toolchain lief zwar, aber jedes seltsame Verhalten wäre „läuft auf nicht unterstütztem Node" gewesen - kein Zustand, in dem man Fehler sucht. Ab CLI 20 ist Node 24 offiziell im engines-Range.
  3. Ökosystem-Zwang. @angular/material, @angular/cdk und @ngrx/* sind an den Angular- Major gekoppelt (Peer-Dependency @angular/core@^20). Sobald eine dieser Bibliotheken ein Feature oder einen Fix bringt, den man braucht, muss Angular mit. Man aktualisiert selten nur Angular allein.
  4. Neue Fähigkeiten & weniger Boilerplate. 18→20 bringt u. a.: standalone als Default (kein standalone: true mehr nötig), provideAppInitializer() statt des sperrigen APP_INITIALIZER-Multi-Providers, schnellere Builds (esbuild/Vite-Pipeline ausgereift), stabilere Signals-APIs, Vorarbeit für zoneless.
  5. Kosten des Wartens wachsen nichtlinear. Vier Majors auf einmal nachzuholen ist deutlich riskanter als jeder einzelne Schritt für sich - die Breaking Changes und ihre Wechselwirkungen stapeln sich. „Klein und regelmäßig" schlägt „selten und groß".

Wann man nicht sofort updaten sollte: kurz vor einem Release, ohne grüne Test-Suite als Sicherheitsnetz, oder wenn eine kritische Dritt-Bibliothek den neuen Major noch nicht unterstützt (vorher auf npm prüfen - siehe Regel 5 unten).


Angulars Release- & Support-Modell (Hintergrundwissen)


Die Regeln einer sicheren Migration

  1. Immer einen Major nach dem anderen. ng update von 18 direkt auf 22 überspringt die Zwischen-Migrationen - viele Schematics sind auf „genau von N auf N+1" ausgelegt. Reihenfolge: 18→19, build+test, 19→20, build+test, ...
  2. Sauberer Git-Baum vor jedem ng update. Das Tool verweigert sonst den Start (und man will die generierten Änderungen ohnehin isoliert reviewen können). Ein Commit pro Major-Schritt.
  3. ng update statt Hand-Editieren der package.json. Nur so laufen die Codemods. Erst danach ggf. manuell nachziehen, was das Tool nicht kann (Dritt-Bibliotheken ohne ng update-Support).
  4. Nach jedem Schritt verifizieren: ng build --configuration production (der AOT-Compiler mit strictTemplates fängt Template-Typfehler, die ng serve teilweise durchlässt) plus ng test plus - wenn möglich - die E2E-Suite gegen den echten Stack.
  5. Ökosystem-Pakete im selben Schritt mitnehmen. ng update listet die auf, die ng update-Schematics mitbringen (@angular/material, @ngrx/store). Bibliotheken ohne Schematics (hier angular-oauth2-oidc) prüft man selbst auf npm (npm view <pkg> peerDependencies) und bumpt sie mit npm install <pkg>@<version>.
  6. Node- und TypeScript-Range prüfen. npm view @angular/core@<ziel> engines bzw. die typescript-Peer-Range. Node ggf. vorher hochziehen.
  7. Optionale Migrationen bewusst entscheiden. ng update bietet am Ende „optional migrations" an (z. B. „auf den neuen Application-Builder", „control-flow-migration"). Die führt man einzeln und begründet aus, nicht reflexartig.

Der konkrete Weg in diesem Projekt

Vorbereitung

git checkout -b chore/angular-20-upgrade
cd library-frontend

# Baseline MUSS grün sein, bevor man anfängt - sonst weiß man später nicht,
# ob ein Fehler vom Update kommt oder schon vorher da war:
export CHROME_BIN="/pfad/zu/chrome.exe"     # nur nötig, wenn Chrome an ungewöhnlichem Ort
npx ng build --configuration production      # -> ok
npx ng test --watch=false --browsers=ChromeHeadless   # -> TOTAL: 8 SUCCESS

Schritt 1: Angular 18 → 19

npx ng update @angular/core@19 @angular/cli@19 @angular/material@19 @ngrx/store@19
npm install angular-oauth2-oidc@^19.0.0 --save     # kein ng-update-Schematic, manuell

Was die Migration automatisch tat:

Manuelle Nacharbeit: Die Schematic formatierte die angefassten @Component-Blöcke mit dem TypeScript-eigenen Printer neu (4-Space-Einrückung, keine Trailing-Commas). Da das Projekt 2-Space + Trailing-Commas nutzt und kein Prettier/ESLint-Autofix konfiguriert ist, wurde die Formatierung der 7 Dateien von Hand zurückgestellt. Merke: ohne projektweiten Formatter erzeugen Angular-Schematics regelmäßig solches Formatierungs-Rauschen - entweder Prettier einführen oder nach jedem ng update den Decorator-Diff kurz glätten.

Verifikation: prod-Build + ng test (8/8) grün. Commit.

Schritt 2: Angular 19 → 20

npx ng update @angular/core@20 @angular/cli@20 @angular/material@20 @ngrx/store@20

Was die Migration automatisch tat:

Aufgetretener Stolperstein: Die @ngrx/store@20-Schematic brach mit Cannot find module '@angular-devkit/core' ab (npm-Hoisting-Problem in @ngrx/*/schematics-core - die Datei wird beim Laden der Migrations-Collection required, bevor npm die Abhängigkeit an die richtige Stelle gehoistet hat). Warum das hier unkritisch war: für den Sprung 19→20 gibt es ohnehin keine anwendbare NgRx-Migration - die jüngste NgRx- Migration ist v18 (Entfernung von TypedAction), und dieser Code ist längst darüber. package.json und package-lock.json waren korrekt geschrieben; ein anschließendes npm install stellte einen konsistenten Zustand her. Bei einem Projekt mit älterem NgRx-Code müsste man die Migration nachholen: npx ng update @ngrx/store --migrate-only --from=18 --to=20.

Verifikation: prod-Build (~24 s) + ng test (8/8) grün. ng version meldet Node jetzt ohne „Unsupported". ng serve startet sauber auf :4200. Commit.

E2E grün: Der Playwright-Golden-Path (library-frontend/e2e/golden-path.spec.ts) lief nach dem Update 1 passed gegen den lokal hochgefahrenen Stack (Podman-Machine, Minimal-Subset via infra/docker-compose.e2e.yml, ng serve auf Angular 20). Zwei aufeinanderfolgende Läufe grün. Damit ist der komplette Pfad Browser → Keycloak-PKCE-Login → api-gateway → Fachservice → Postgres → zurück ins Angular-20-Frontend verifiziert. Runbook: Teststrategie → E2E.

Nachgezogen: APP_INITIALIZERprovideAppInitializer()

Nicht Teil des mechanischen ng update (es gibt dafür keinen Codemod), aber APP_INITIALIZER ist seit v19 @deprecated. app.config.ts wurde auf die moderne Form umgestellt:

// vorher (v18):
{ provide: APP_INITIALIZER, useFactory: (auth: AuthService) => () => auth.initialize(),
  deps: [AuthService], multi: true }

// nachher (v19+):
provideAppInitializer(() => inject(AuthService).initialize())

Die Initializer-Funktion läuft im Injection-Kontext - daher inject(AuthService) statt einer deps-Liste. Kürzer, typsicher, keine multi-Token-Mechanik.

Schritt 3: Angular 20 → 21

npx ng update @angular/core@21 @angular/cli@21 @angular/material@21 @ngrx/store@21
npm install angular-oauth2-oidc@^21.0.0 --save

Schritt 4: Angular 21 → 22 (latest)

Hier ging der übliche Weg nicht durch: npx ng update blieb auf dieser Maschine reproduzierbar bei „Installing a temporary Angular CLI versioned 22 to perform the update" hängen (die Prozesse liefen, taten aber nichts - ein Umgebungsproblem mit dem Temp-CLI-Mechanismus, kein Angular-Bug). Workaround - ng update ohne den Temp-CLI-Mechanismus:

# 1. Versionen manuell in package.json auf v22 setzen (core/cli/material/cdk 22.1.x,
#    @ngrx/* 22.0.0, angular-oauth2-oidc 22.0.2, zone.js ~0.16, typescript ~6.0.3).
#    ACHTUNG: @angular/compiler-cli hat max 22.1.4, @angular/cli/build-angular gehen auf 22.1.6.
# 2. Sauber neu installieren (die alte node_modules verwirrte npms ERESOLVE):
rm -rf node_modules package-lock.json && npm install
# 3. Die Codemods mit der JETZT lokal installierten v22-CLI laufen lassen:
npx ng update @angular/core    --migrate-only --from=21.2.22 --to=22.1.4 --allow-dirty
npx ng update @angular/material --migrate-only --from=21.2.14 --to=22.1.4 --allow-dirty

Was die v22-Migration am Code geändert hat:

Änderung wo warum
changeDetection: ChangeDetectionStrategy.Eager an allen 7 Komponenten *.component.ts v22 macht OnPush zum Default für neu generierte Komponenten. Eager ist der explizite Opt-out, den die Migration setzt, um das bisherige („check always") Verhalten zu erhalten.
withXhr() in provideHttpClient(...) app.config.ts v22-Default des HTTP-Backends ist jetzt fetch statt XMLHttpRequest. withXhr() erhält das bisherige XHR-Verhalten.
extendedDiagnostics: { nullishCoalescingNotNullable, optionalChainNotNullable: "suppress" } tsconfig.app.json, tsconfig.spec.json v22 führt zwei neue, strengere Template-Diagnosen ein; die Migration schaltet sie auf suppress, damit kein bestehendes Template plötzlich Warnungen wirft.

Was sich 18 → 22 geändert hat - Überblick

Änderung Version Für diesen Code Automatisch?
standalone ist Default v19 standalone: true entfernt (7 Dateien) ✅ Schematic
APP_INITIALIZER deprecated → provideAppInitializer() v19 app.config.ts umgestellt ❌ manuell
provideExperimentalZonelessChangeDetectionprovideZonelessChangeDetection v20 n/a (Projekt nutzt zonen-basierte CD) ✅ Schematic
Karma gilt als deprecated; Vitest-Runner (@angular/build:unit-test) als Nachfolger (noch experimentell) ~v20 Karma läuft weiter, bewusst behalten (siehe unten) -
ng generate-Namenskonventionen (foo.ts statt foo.component.ts optional, .-Separator) v20 nur angular.json-Defaults, kein Bestandscode ✅ Schematic
TypeScript-Mindestversion 5.9 v20 ~5.9.3, Build unverändert grün ng update
Node-engines erweitert auf Node 24 v20 „Unsupported"-Warnung weg ng update
esbuild/Vite-Build-Pipeline (Application-Builder) seit v17/18 stabil Projekt nutzt bereits @angular-devkit/build-angular:application -
tsconfig "lib"-Liste wird abgeleitet v21 explizites "lib": ["ES2022","dom"] entfernt ✅ Schematic
OnPush als Default für neue Komponenten v22 changeDetection: ChangeDetectionStrategy.Eager an allen 7 Komponenten (Verhalten erhalten) ✅ Schematic
HTTP-Backend-Default: fetch statt XHR v22 withXhr() in provideHttpClient(...) (Verhalten erhalten) ✅ Schematic
Neue strengere Template-Diagnosen v22 nullishCoalescingNotNullable/optionalChainNotNullable auf suppress in tsconfig.app/spec.json ✅ Schematic
TypeScript-Mindestversion 6.0 v22 ~6.0.3 - erster TS-Major-Sprung, Build unverändert grün ✅ manuell (npm)
@angular/platform-browser-dynamic / @angular/animations / @angular-devkit/build-angular deprecated v22 npm-Warnungen; alle drei laufen weiter -

Nicht betroffen (bewusst erwähnt, damit klar ist, wie wenig „echte" Migration hier anfiel): Angular Signals, Router (loadComponent, funktionale Guards), HttpClient + funktionaler Interceptor, Reactive Forms, Angular-Material-Komponenten-APIs, sämtliche NgRx-APIs (createActionGroup, createReducer, createEffect, store.selectSignal, ...). Alles unverändert lauffähig.


Was bewusst NICHT gemacht wurde

Offen gelassen Warum Wann nachholen
withXhr() wieder entfernen (fetch-Backend nutzen) Zwei Verhaltensänderungen (Major-Upgrade + HTTP-Backend) in einem Commit erschweren die Fehlersuche. withXhr() bleibt vorerst - der fetch-Default ist aber der empfohlene v22-Weg und unsere GET/POST-Nutzung hängt an keiner XHR-Eigenheit. Eigener kleiner Commit + E2E
ChangeDetectionStrategy.EagerOnPush Der ganze Zustand läuft über Signals - OnPush wäre der passende Default. Aber jede Komponente/jedes Template einzeln prüfen ist ein Refactor, kein Update-Nebeneffekt. Eigenes Vorhaben, siehe angular-konzepte.md
Karma → Vitest-Runner Der neue @angular/build:unit-test-Runner ist noch experimentell; die eine Shell-Spec läuft unter Karma stabil. Eigene Entscheidung, eigener Commit. Wenn Karma hart entfernt wird oder mehr Specs dazukommen
@angular/platform-browser-dynamic entfernen In src/ nicht importiert, nur noch deprecated-Warnung. Kann aus package.json raus. Eigener Aufräum-Commit
use-application-builder / control-flow-migration (optionale Migrationen) Projekt nutzt den Application-Builder bereits und @if/@for statt *ngIf/*ngFor - beide gegenstandslos -
Zoneless Change Detection Größere Umstellung (hängt an der OnPush-Umstellung), kein reines Update-Thema Eigenes Vorhaben, siehe angular-konzepte.md

Aktueller Stand & nächster Sprung

Das Frontend ist auf Angular 22 (latest). Der nächste Major (v23) erscheint nach Angulars Rhythmus etwa ein halbes Jahr nach v22 - dann derselbe Ablauf: majorweise, ein Commit pro Schritt, Build + Unit-Tests + E2E nach jedem. update.angular.dev „von 22 auf 23" vorher lesen. Vor dem v23-Sprung idealerweise die oben unter „Was bewusst NICHT gemacht wurde" gelisteten Aufräumpunkte abarbeiten (withXhr() raus, OnPush, ggf. Vitest-Runner) - je weniger behaviour-preserving-Ballast, desto glatter der nächste ng update.


Runbook (kopierbare Checkliste je Major-Schritt)

[ ] git-Baum sauber, auf Upgrade-Branch
[ ] update.angular.dev für "von N auf N+1" gelesen
[ ] node -v gegen `npm view @angular/core@<N+1> engines` geprüft
[ ] Baseline grün: ng build --configuration production  &&  ng test
[ ] npx ng update @angular/core@<N+1> @angular/cli@<N+1> @angular/material@<N+1> @ngrx/store@<N+1>
[ ]   -- falls "Installing a temporary Angular CLI ..." haengt: Versionen manuell in
[ ]      package.json, `rm -rf node_modules package-lock.json && npm install`, dann
[ ]      `npx ng update @angular/core --migrate-only --from=<X> --to=<Y> --allow-dirty`
[ ] @angular/compiler-cli-Maxversion pruefen (kann hinter @angular/cli zurueckliegen)
[ ] Dritt-Libs ohne Schematic manuell: npm view <lib> peerDependencies  ->  npm install <lib>@<x>
[ ] git diff: sind Schematic-Änderungen plausibel? Formatierungs-Rauschen glätten
[ ] deprecation-Warnungen (npm-install + Build-Output) prüfen, ggf. manuell nachziehen
[ ] ng build --configuration production   (AOT + strictTemplates!)
[ ] ng test --watch=false --browsers=ChromeHeadless
[ ] E2E gegen echten Stack (wenn Infra verfügbar): npm run e2e
[ ] optionale Migrationen einzeln bewertet
[ ] ein Commit pro Major-Schritt, aussagekräftige Message

Troubleshooting (real aufgetreten)

Symptom Ursache Lösung
ng update bricht ab: „repository is not clean" uncommittete Änderungen committen oder stashen, dann erneut
npm error could not determine executable to run ng update nicht aus dem Angular-Projektverzeichnis gestartet in library-frontend/ wechseln (dort liegt node_modules/.bin/ng)
Cannot find module '@angular-devkit/core' in einer @ngrx-Schematic npm-Hoisting: @ngrx/*/schematics-core findet die Devkit-Abhängigkeit nicht prüfen ob für den Sprung überhaupt eine NgRx-Migration nötig ist; sonst npm install zur Konsolidierung und weiter. Nachholen: ng update @ngrx/store --migrate-only --from=<x> --to=<y>
ng version: Node: … (Unsupported) Node-Version außerhalb der engines-Range des aktuellen Angular Angular hochziehen (behebt sich hier durch das Update selbst) oder Node auf eine unterstützte LTS wechseln
ng update hängt bei „Installing a temporary Angular CLI versioned N" (Prozesse leben, 0 % CPU) Umgebungsproblem mit dem Temp-CLI-Download/-Install (hier reproduzierbar für den 21→22-Schritt) Versionen manuell in package.json, rm -rf node_modules package-lock.json && npm install, dann npx ng update @angular/core --migrate-only --from=<X> --to=<Y> --allow-dirty mit der jetzt lokal installierten Ziel-CLI
npm ERESOLVE: „Found: @angular/compiler-cli@undefined" die gepinnte Version existiert nicht (z. B. @angular/compiler-cli@22.1.6, obwohl max 22.1.4) Framework-Pakete (core, compiler-cli) und Tooling (cli, build-angular) können unterschiedliche jüngste Patch-Versionen haben - jede einzeln per npm view <pkg>@<major> version prüfen
npm ERESOLVE trotz korrekter Versionen in package.json veraltete node_modules verwirren npms Peer-Auflösung rm -rf node_modules package-lock.json && npm install (frische Auflösung)
ng test hängt in Karma (Bundle gebaut, aber 0 Headless-Chrome, keine TOTAL-Zeile) Browser-Disconnect unter Systemlast Prozess killen, ng test erneut - auf ausgelasteter Maschine sporadisch
@Component-Blöcke plötzlich 4-Space eingerückt TS-Printer der Migrations-Schematic, kein projektweiter Formatter Diff glätten oder Prettier einführen
Template-Typfehler erst im prod-Build, nicht bei ng serve ng serve prüft strictTemplates weniger streng immer ng build --configuration production als Gate nehmen

Weiterführend

⌂ Cockpit