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.
- Four SDK functions we already used
- Fourteen callers unchanged
- Core, CLI, Material, TypeScript 5.9
provideZoneChangeDetection()
- 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
AppModule, so AppServerModule gets it too.
Uint8Array<ArrayBuffer> before getByteFrequencyData would type-check.
ng update rewrote them to @if. Four templates broke. We put them back. That migration is its own project.
| Library | From | To |
|---|---|---|
| @angular/core, common, … | 20.3 | 21.2.23 |
| CLI, SSR, build-angular | 20.3 | 21.2.24 |
| Material, CDK | 20.2 | 21.2.14 |
| primeng | 20.4 | 21.1.10 |
| ngx-bootstrap | 20.0 | 21.2.2 |
| ng-select | 20.4 | 21.8.2 |
| ngrx/store | 20.1 | 21.1.1 |
| angular-fontawesome | 2.0 | 4.0, svg-core 7.3 |
| angular-eslint | 19.8 | 21.4 |
| @angular/fire | 20.0 | removed |
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.
getApps()[0] ?? initializeApp(config) so white-label builds share the same code and swap config via fileReplacements.
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.
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
strictTemplatesoff. 49window:scrollandwindow:resizelisteners 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 turntypeCheckHostBindingsoff.
- Symptom
- Signup's country dropdown died with
forRoot is not a function. - Cause
ngx-intl-tel-input15 still callsBsDropdownModule.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
DashboardModulelistedBsModalServicein its providers, so the tag dialog got a second instance. It calledhide()on a service that had never opened anything.- Fix
- Inject
BsModalRefand 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
selectTabon 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
MESSAGESbefore 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
formControlNameown the value. Set the ID, thenstartCamera(). Close withBsModalRef. Only the last one is a 21 bug; the rest were older and hidden behind it.
- Symptom
NG8113warnings 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.
- Stock 15.0.0 lands from the registry
- The broken
forRoot()call is back
- npm sees
"postinstall": "patch-package" - Runs it once, after every package is on disk
- Reads
patches/ngx-intl-tel-input+15.0.0.patch - Name pins the package and the version
NgxIntlTelInputModulenow loads the patched copy- Three built files, one line each
| Name | What it does here |
|---|---|
postinstall | npm lifecycle hook in package.json. Ours runs one command. |
patch-package | CLI in devDependencies. Applies every diff under patches/ to node_modules. |
hasInstallScript | Lockfile 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.