21 is the LTS we stop on. Support runs through June 2027. Angular asked for one provider and TypeScript 5.9. The real work was dropping AngularFire and surviving ngx-bootstrap 21. NgModules and zone.js stay.

Same rule as every earlier bump

One change at a time. Firebase moved while we were still on 20, so a broken push token could not be blamed on the framework.

Still on 20
Firebase SDK, no AngularFire
  • Four SDK functions we already used
  • Fourteen callers unchanged
Framework
20 to 21
  • Core, CLI, Material, TypeScript 5.9
  • provideZoneChangeDetection()
Libraries
The ones that reject 21
  • PrimeNG, ngx-bootstrap, ng-select, ngrx
  • Font Awesome 7 with it

ng update needed --force. ng-ivs still peers Angular 12, the audio player still peers Material 17, ngx-stripe still peers Angular 18. They already ran that way on 20. The flag skips the peer check, not the migrations.

What Angular itself changed

zone scheduler, implicitprovideZoneChangeDetection() 21 no longer starts it for you. Without this line the app paints once and never updates. It lives in AppModule, so AppServerModule gets it too.
TypeScript 5.8.35.9.3 Required. The voice recorder buffer had to become Uint8Array<ArrayBuffer> before getByteFrequencyData would type-check.
@HostListener('window:scroll', ['$event'])@HostListener('window:scroll') 21 type-checks host bindings. 49 handlers took no parameter. The event was unused, so we stopped passing it.
control-flow schematic, 659 templatesreverted, still *ngIf ng update rewrote them to @if. Four templates broke. We put them back. That migration is its own project.
LibraryFromTo
@angular/core, common, …20.321.2.23
CLI, SSR, build-angular20.321.2.24
Material, CDK20.221.2.14
primeng20.421.1.10
ngx-bootstrap20.021.2.2
ng-select20.421.8.2
ngrx/store20.121.1.1
angular-fontawesome2.04.0, svg-core 7.3
angular-eslint19.821.4
@angular/fire20.0removed

Firebase without AngularFire

AngularFire's only 21 build is a November 2025 RC that pulls Firebase 12. We use four SDK functions. Calling them ourselves keeps Firebase at 11.10.0, the version already installed.

The public FirebaseService API did not change, so its 14 callers did not either. The service worker never depended on AngularFire.

provideFirebaseApp, provideMessaging, provideRemoteConfigremoved from AppModule Init moved into the service. One getApps()[0] ?? initializeApp(config) so white-label builds share the same code and swap config via fileReplacements.
onMessage only when the token changedonMessage every session A returning user with a saved token never got a foreground listener. Register the token when it changes. Attach the listener either way.
SDK callbacks, unzonedNgZone.run() for UI state AngularFire did this for us. Remote Config writes iosPayButtonsConfig inside the zone so the view updates.
firebase.service.ts+ import { getApps, initializeApp } from 'firebase/app';+ import { getMessaging, getToken, onMessage, isSupported as isMessagingSupported } from 'firebase/messaging';+ import { getRemoteConfig, fetchAndActivate, getValue, isSupported as isRemoteConfigSupported } from 'firebase/remote-config'; + private async getMessagingInstance() {+   if (!this.platformService.isBrowser) return null;+   if (!(await isMessagingSupported())) return null;+   this.messaging = getMessaging(this.getFirebaseApp());+   return this.messaging;+ }registerWebToken+ const token = await getToken(messaging, { vapidKey: environment.firebaseVapidKey });+ this.setConfigToken(token, false);+ this.onMessage();getRemoteConfigValue+ this.zone.run(() => { this.iosPayButtonsConfig = parsed; });

ngx-bootstrap 21

Module.forRoot() is gone. Every module is imported bare. BsDropdownDirective.isOpen is a getter now, so code that assigned it calls toggle() or hide() instead.

ModalModule.forRoot()ModalModule Same for dropdown, datepicker, tabs, accordion, typeahead. App-wide, mechanical.
dropdown.isOpen = falsedropdown.hide() or toggle() Home discover, forum, collaborators, post-detail, post-submit. Assigning the getter does not compile.
ngx-intl-tel-input calls BsDropdownModule.forRoot()patched to import the module The package is still 15 and has no 21 release. patch-package rewrites three build outputs. postinstall applies it.
shared.module.ts, and every feature module- ModalModule.forRoot(),- BsDropdownModule.forRoot(),+ ModalModule,+ BsDropdownModule,home.component.ts- dropdown.isOpen = false;+ dropdown.toggle();patches/ngx-intl-tel-input+15.0.0.patch- export const dropdownModuleForRoot = BsDropdownModule.forRoot();+ export const dropdownModuleForRoot = BsDropdownModule;

What broke

Symptom
The build failed on host listeners. Handlers that take no arguments were being passed $event.
Cause
21 type-checks host bindings even with strictTemplates off. 49 window:scroll and window:resize listeners were written that way, mostly infinite-scroll lists.
Fix
Drop the ['$event'] args list. Behaviour is the same. The listeners still fire. We did not turn typeCheckHostBindings off.
Symptom
Signup's country dropdown died with forRoot is not a function.
Cause
ngx-intl-tel-input 15 still calls BsDropdownModule.forRoot() inside its own module. ngx-bootstrap 21 removed that method.
Fix
A three-file patch that imports the module directly. If the phone flags ever look unstyled, the lazy vendor CSS for intl-tel-input did not load. That path did not change.

Found after the merge

Symptom
Tag a user in a new post, press close. The modal stays.
Cause
DashboardModule listed BsModalService in its providers, so the tag dialog got a second instance. It called hide() on a service that had never opened anything.
Fix
Inject BsModalRef and hide that. Drop the extra provider. ngx-bootstrap 20 tolerated the duplicate; 21 did not.
Symptom
Open Messages. The tab is highlighted, the pane is empty.
Cause
ngx-bootstrap 21 fires selectTab on init with the heading as rendered, "Messages". Our active check compared it to the enum value for the small-screen variant and lost.
Fix
Map the small-screen heading back to MESSAGES before the comparison, and accept either in the template.
Symptom
Livestream preview opened the webcam and never let go. Test Video showed an error instead of the camera.
Cause
Four things. The permission check kept a track open. Two handlers wrote the webcam control. The modal started before it had the device ID. Close went through the global BsModalService, the same mismatch as the tag dialog.
Fix
Stop the permission stream once the device list loads. Let formControlName own the value. Set the ID, then startCamera(). Close with BsModalRef. Only the last one is a 21 bug; the rest were older and hidden behind it.
Symptom
NG8113 warnings in the build log, six standalone components.
Cause
Since 19 the compiler reports imports a standalone template never uses. CurrencyPipe, InfoModalComponent, LanguageSwitcherComponent, two directives. Copied in, never bound.
Fix
Removed them. A warning today, and the kind that turns into an error at the next major.

How the phone-input patch survives npm install

node_modules is a cache. Delete it and you get what the author published, which for ngx-intl-tel-input 15 still calls BsDropdownModule.forRoot(). Git stores our edit as a diff. postinstall replays it. Skip either half and the build is red again.

1
Unpack
  • Stock 15.0.0 lands from the registry
  • The broken forRoot() call is back
2
Hook
  • npm sees "postinstall": "patch-package"
  • Runs it once, after every package is on disk
3
Match and apply
  • Reads patches/ngx-intl-tel-input+15.0.0.patch
  • Name pins the package and the version
4
Build
  • NgxIntlTelInputModule now loads the patched copy
  • Three built files, one line each
NameWhat it does here
postinstallnpm lifecycle hook in package.json. Ours runs one command.
patch-packageCLI in devDependencies. Applies every diff under patches/ to node_modules.
hasInstallScriptLockfile flag npm sets on any package with an install hook. Ours, esbuild, protobufjs. It means "runs a script", not "malware".

Bumping the library breaks the patch on purpose: the filename no longer matches, install fails, someone has to look. To remake it, edit the files under node_modules/ngx-intl-tel-input until ng build passes, then npx patch-package ngx-intl-tel-input and commit the new file.