TopFans web frontend. Five majors to the current LTS, two bundlers, one SSR engine swap, one PrimeNG theme-system swap. NgModules and zone.js stay where they are.
What moved
| Thing | Before | After |
|---|---|---|
| Angular | 15.2.10 | 20.3.31 |
| TypeScript | 4.9.5, moduleResolution: node | 5.8.3, bundler |
| zone.js | 0.11 | 0.15 |
| rxjs | 7.5 | 7.8 |
| Node | node:20-slim, unpinned, CI on 18 | 20.19.6 pinned, CI and functions on 20 |
| Client builder | browser (webpack) | application (esbuild) |
| SSR engine | @nguniversal ngExpressEngine | @angular/ssr CommonEngine |
| Server bundle | server/main.js (CJS) | server/server.mjs (ESM) |
| PrimeNG | 15.4, saga-blue theme CSS | 20.4, Lara preset from @primeuix/themes |
| Angular Material | 15.2 | 20.2 |
| Component flag | implicit | standalone: false, explicit |
| Vendor CSS | all in styles.css | 12 lazy bundles, loaded per widget |
Where the effort went
require() failed at runtime, not at build.
Not done, on purpose: standalone components, signals. Last page.
Order of work
- Follow-up for API migrations
ng updatemissed
@nguniversalto@angular/ssr- PrimeNG 17 and Material 17 fixes
require()crashes- Import cycle
- One build target, ESM server
- Prerender bugs, IVS import
- Seven files
- DOMPurify interop
standalone: false, 490 times- SSR
allowedHosts - Node 20 in CI
- 12 sheets out of
styles.css - Loaded by the widget
- PrimeNG 17 to 20, Lara
DOCUMENTfrom core- Class renames, carousel
One change per bump: versions and schematic migrations only. Fixes land separately. The bundler moved on a fixed framework version, so every breakage points at exactly one cause. Each page ahead has the same shape: what changed, then a slide on what broke. We stop at 20: it is on LTS into 2027, and 21 and 22 add nothing this app needs yet.
The smallest step. Mostly removing deprecated shapes the framework had been warning about since 15.2. Still on webpack and @nguniversal.
Environment and config
| Thing | Before | After |
|---|---|---|
| Node | node:20-slim, floating | 20.19.6 in .nvmrc and Dockerfile |
| TypeScript | 4.9.5 | 5.1.6 |
| zone.js | 0.11 | 0.13 |
| rxjs | 7.5 | 7.8 |
| zone import | zone.js/dist/zone-node | zone.js/node |
| typeRoots | @types | node_modules/@types |
buildOptimizer | implied | explicit per configuration |
| angular-eslint | 15.2 | 16.3, plus builder, schematics, template-parser |
server.ts, serverless.ts- import 'zone.js/dist/zone-node';+ import 'zone.js/node';Dockerfile- FROM public.ecr.aws/docker/library/node:20-slim+ FROM public.ecr.aws/docker/library/node:20.19.6-slim.nvmrc (new)+ 20.19.6angular.json, every configuration+ "buildOptimizer": true
Framework API changes
CanDeactivate<T>. The interfaces are deprecated; the router calls canActivate() by name, so the classes keep working unchanged.
SharedModule still had one.
makeStateKey. Three services: ApiCacheService, PlatformService, CacheService.
src/app/core/auth-guard.ts- import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router';+ import { ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router';- export class AuthGuard implements CanActivate {+ export class AuthGuard {src/app/app.module.ts- BrowserModule.withServerTransition({ appId: 'serverApp' }),+ BrowserModule,src/app/core/cache/api-cache.service.ts- import { Injectable } from '@angular/core';- import { TransferState, makeStateKey } from '@angular/platform-browser';+ import { Injectable, TransferState, makeStateKey } from '@angular/core';
Third-party libraries
| Library | From | To | Code change |
|---|---|---|---|
| ngx-mask | 12 | 16 | Module became directive + pipe + provideNgxMask() |
| primeng | 15.4 | 16.9 | MultiSelect removeChip is removeOption |
| ngx-drag-and-drop-lists | 1.3 | removed | DndListModule was imported and unused |
| ngx-bootstrap | 8 | 11 | none |
| ng-bootstrap | 12 | 15 | none |
| ng-select | 8 | 11 | none |
| ngrx/store | 13 | 16 | none |
| ngx-material-timepicker | 5 | 13 | none |
| stripe-js / ngx-stripe | 1 / 13 | 3 / 16 | none |
| angular-fontawesome | 0.7 | 0.13 | none |
| domino, luxon | transitive | explicit | none |
src/app/modules/wallet/wallet.module.ts- import { NgxMaskModule, IConfig } from 'ngx-mask';+ import { NgxMaskDirective, NgxMaskPipe, provideNgxMask, IConfig } from 'ngx-mask'; imports: [- NgxMaskModule.forRoot(),+ NgxMaskDirective,+ NgxMaskPipe, ],- providers: [DatePipe, WalletHomeService]+ providers: [DatePipe, WalletHomeService, provideNgxMask()]custom-multiselect.component.ts- multiSelect.removeChip(item, event);+ multiSelect.removeOption(item, event);
What broke
- Symptom
- Build green after the bump. Deprecated APIs still live in five files.
- Cause
ng updateschematics do not rewrite every pattern, and deprecations compile without complaint.- Fix
-
A follow-up change:
WhitelabelSiteOnlyGuardstill implementingCanActivateTransferStatestill fromplatform-browserin three serviceswithServerTransitionstill inAppModule
ng update.
the follow-up, 5 files src/app/app.module.ts | 2 +- src/app/core/cache/api-cache.service.ts | 3 +-- src/app/core/services/platform.service.ts | 9 +++++++-- src/app/core/whitelabel-site-only.guard.ts | 4 ++-- src/app/modules/shared/services/cache.service.ts | 3 +--
The SSR engine swap. @nguniversal is gone and @angular/ssr takes its place. The client build stays on webpack for now.
Environment and config
| Thing | Before | After |
|---|---|---|
| Node | 20.19.6 | keep, Node 18 is end of life |
| TypeScript | 5.1.6 | 5.4.5 |
| zone.js | 0.13 | 0.14 |
| ESLint | 8.12 | 8.57 |
| typescript-eslint | 5 | 7 |
| angular-eslint | 16.3 | 17.5 |
.gitignore | +20 patterns for generated white-label files | |
tsconfig.server.json | server.ts | + serverless.ts |
angular.json- "browserTarget": "TopFans:build:production"+ "buildTarget": "TopFans:build:production"- "builder": "@nguniversal/builders:ssr-dev-server",+ "builder": "@angular-devkit/build-angular:ssr-dev-server",- "builder": "@nguniversal/builders:prerender",+ "builder": "@angular-devkit/build-angular:prerender",- "guessRoutes": false+ "discoverRoutes": falsepackage.json- "@nguniversal/builders": "~16.2.0",- "@nguniversal/express-engine": "~16.2.0",+ "@angular/ssr": "~17.3.17",
SSR engine
render() returns HTML, so one renderApp(req, res, next) helper replaces the three res.render('index') call sites, 404 fallback included.
REQUEST and RESPONSE are our own InjectionTokens now. NotFoundComponent imports from there.
serverless.ts. inlineCriticalCss: true kept on both paths.
server.ts- import { ngExpressEngine } from '@nguniversal/express-engine';+ import { APP_BASE_HREF } from '@angular/common';+ import { CommonEngine } from '@angular/ssr';+ import { REQUEST, RESPONSE } from './src/app/core/express.tokens';- server.engine('html', ngExpressEngine({ bootstrap: AppServerModule, inlineCriticalCss: true }));+ const commonEngine = new CommonEngine();+ const renderApp = (req, res, next?) =>+ commonEngine.render({+ bootstrap: AppServerModule,+ documentFilePath: join(distFolder, 'index.html'),+ url: `${req.protocol}://${req.headers.host}${req.originalUrl}`,+ publicPath: distFolder,+ inlineCriticalCss: true,+ providers: [+ { provide: APP_BASE_HREF, useValue: req.baseUrl || '/' },+ { provide: REQUEST, useValue: req },+ { provide: RESPONSE, useValue: res }+ ]+ }).then((html) => res.send(html)).catch((err) => next?.(err));- return res.render('index', { req, providers: [{ provide: 'REQUEST', useValue: req }] });+ return renderApp(req, res);
Templates, router, libraries
@ is reserved for control flow now. One occurrence, in post-price.component.html. The compiler found it.
TrailingSlashUrlSerializer. Router 17 throws on malformed input the old one swallowed.
| Library | From | To |
|---|---|---|
| primeng | 16.9 | 17.18, see next slides |
| @angular/fire | 7 | 17 |
| @zxing/ngx-scanner | 3 | 17 |
| ng-select | 11 | 12 |
| ngrx/store | 16 | 17 |
| ngx-bootstrap | 11 | 12 |
| ng-bootstrap | 15 | 16 |
| angular-fontawesome | 0.13 | 0.14 |
| ngx-stripe | 16 | 17 |
What broke: audio player
- Symptom
- Seeking does nothing;
audio.currentTimebecomesNaN. Card, button and slider styles off. - Cause
- Material 17's slider is the MDC one.
changeis a plain DOM event with no.value, the seek input isinput[matSliderThumb], and track clicks fireinput, notchange.ngx-audio-playerstill readsevent.value. - Fix
AudioSliderPatchDirectiveon the player host: capturechangeandinput, resolve the value from the thumb input, define.valueon the event, setaudio.currentTimeitself. Selectors rewritten in six global scss files and nine component files.
audio-slider-patch.directive.ts- this.el.nativeElement.addEventListener('change', this.changeListener, true);+ this.el.nativeElement.addEventListener('change', this.sliderListener, true);+ this.el.nativeElement.addEventListener('input', this.sliderListener, true); + private resolveSliderValue(e: Event): number {+ const target = e.target as HTMLElement | null;+ if (!target?.closest?.('mat-slider, .mat-mdc-slider')) return NaN;+ if (target instanceof HTMLInputElement && Number.isFinite(target.valueAsNumber)) {+ return target.valueAsNumber;+ }+ const thumbInput = this.el.nativeElement.querySelector(+ 'input[matSliderThumb], input.mdc-slider__input, input[type="range"]'+ ) as HTMLInputElement | null;+ return thumbInput?.valueAsNumber ?? NaN;+ }
What broke: custom multiselect
- Symptom
- Placeholder gone, selected label empty, duplicate options cannot be selected, parent control never marked touched or disabled.
- Cause
-
PrimeNG 17 MultiSelect:
[defaultLabel]is[placeholder]valuesAsStringislabel()- Selection compares by reference
- Fix
- Own the state. Internal
FormControl<string[]>; every option is identified by a requireddataKeyin its config (skornametoday), so identity is explicit and one to one. Callers replace the options array instead of mutating it, and generated features get a uniquename. Sync back to the parent andmarkAsDirty;onBlurandonPanelHidemark touched;statusChangesmirrors disabled. Six passes to land on this.
custom-multiselect.component.html- [defaultLabel]="field?.customMultiselectConfig?.defaultLabel"+ [placeholder]="field?.customMultiselectConfig?.defaultLabel"- {{ multiSelect.valuesAsString }}+ {{ multiSelect.label() }}+ (onBlur)="markParentControlAsTouched()"+ (onPanelHide)="markParentControlAsTouched()"custom-multiselect.component.ts+ selectionControl = new FormControl<string[]>([], { nonNullable: true });+ private optionByInternalId = new Map<string, unknown>();+ private internalIdByOption = new Map<unknown, string>(); + private getOptionIdentity(option: unknown): string | null {+ const value = option[this.field?.customMultiselectConfig?.dataKey];+ return value == null || value === '' ? null : String(value);+ }formBuilderInterface.ts, every consumer- dataKey?: string;+ dataKey: string;+ dataKey: 'sk'create-plan.component.ts- this.featuresField.options.push(newFeature);+ this.featuresField.options = [...this.featuresField.options, newFeature];
Angular's drop-in bundler swap: identical angular.json shape, identical dist/ layout, esbuild underneath. Still on Angular 17. Faster compiles, at the cost of two runtime crash fixes and one live stream fix.
What the config gave up
/flags relatively and the new bundler did not copy the folder.
LazyCssBundle is now keyof typeof that map.
polyfills.ts. Dead shim.
angular.json- "builder": "@angular-devkit/build-angular:browser",+ "builder": "@angular-devkit/build-angular:browser-esbuild",- "vendorChunk": true, "stylePreprocessorOptions": { "includePaths": [- "sass"+ "sass",+ "." ] }- { "input": "node_modules/flag-icon-css/css/flag-icons.min.css",- "bundleName": "lazy-flag-icons", "inject": false }+ { "glob": "flag-icons.min.css", "input": "node_modules/flag-icon-css/css", "output": "/" },+ { "glob": "**/*", "input": "node_modules/flag-icon-css/flags", "output": "/flags" }lazy-css-loader.service.ts- link.href = `/${bundle}.css`;+ link.href = LAZY_CSS_LOADER_CONSTANTS.hrefs[bundle];
What broke: require() at runtime
- Symptom
- Build green. App crashes on load:
require is not defined. - Cause
-
Webpack rewrote
require()to__webpack_require__. esbuild emits ESM and leaves it alone.TextEditorComponent:require('quill')inngOnInitto keep Quill off the serverCommonService:require('../../../core/auth.service')to dodge a cycle,AuthServicetoCommonServiceand back
- Fix
- Static imports. Quill unwrapped with
?.default ?? QuillDefault.CommonServiceimportsAuthServicenormally, and the cycle itself is gone:CircleBackgroundIconComponentandInfoIconComponentonly wantedisBrowser, so they injectPLATFORM_IDinstead of the service.
text-editor.component.ts- let Quill: any;- Quill = require('quill');- require('quill-emoji');+ import QuillDefault from 'quill';+ import 'quill-emoji';+ const Quill: any = (QuillDefault as any)?.default ?? QuillDefault;common.service.ts- const { AuthService } = require('../../../core/auth.service');+ import { AuthService } from '@core/auth.service';info-icon.component.ts- import { CommonService } from 'src/app/modules/shared/services/common.service';- constructor(private cs: CommonService) {}- if (this.cs.isBrowser) {+ constructor(@Inject(PLATFORM_ID) private readonly platformId: object) {}+ if (isPlatformBrowser(this.platformId)) {
What broke: live streaming
- Symptom
- Broadcasts fail to start. Found last, because nobody tested going live.
- Cause
-
await import('amazon-ivs-web-broadcast')returns a different shape per bundler.module.default = IVSBroadcastClientmodule.default = exports Under esbuild's CJS interop the class sits atmodule.default.defaultand the presets atmodule.default.*. - Fix
- Detect the shape by a known preset, then unwrap
.default ?? exports. Works under both bundlers.
active-livestream.component.ts- const IVSBroadcastClient = ivsModule?.default;+ const ivsExports = ivsModule?.default?.BASIC_FULL_HD_LANDSCAPE+ ? ivsModule.default+ : ivsModule;+ const IVSBroadcastClient = ivsExports?.default ?? ivsExports; - channelType === 'STANDARD' && ivsModule?.STANDARD_LANDSCAPE- ? ivsModule.STANDARD_LANDSCAPE- : ivsModule?.BASIC_FULL_HD_LANDSCAPE;+ channelType === 'STANDARD' && ivsExports?.STANDARD_LANDSCAPE+ ? ivsExports.STANDARD_LANDSCAPE+ : ivsExports?.BASIC_FULL_HD_LANDSCAPE;- this.bindIvsBroadcastNetworkListeners(ivsModule);+ this.bindIvsBroadcastNetworkListeners(ivsExports);
Browser, server and prerender used to be three architect targets with five environment configurations each. Now it is one build target, an ESM server, a new src/server-dom.ts, and a rewritten white-label generator.
angular.json: three targets become one
browser/ and server/ itself.
prerender.routesFile and discoverRoutes: false.
angular.json- "builder": "@angular-devkit/build-angular:browser-esbuild",+ "builder": "@angular-devkit/build-angular:application",- "outputPath": "dist/TopFans/browser",- "main": "src/main.ts",- "polyfills": "src/polyfills.ts",- "ngswConfigPath": "ngsw-config.json"+ "outputPath": "dist/TopFans",+ "browser": "src/main.ts",+ "server": "src/main.server.ts",+ "ssr": { "entry": "server.ts" },+ "polyfills": ["src/polyfills.ts"],+ "prerender": false,+ "externalDependencies": ["domino", "express", "compression", "helmet"]configurations.production- "serviceWorker": true,- "commonChunk": true,- "buildOptimizer": true,+ "serviceWorker": "ngsw-config.json",+ "prerender": { "routesFile": "prerender.routes.txt", "discoverRoutes": false }
server.ts becomes an ES module
server.mjs. Compares against process.argv[1]; the webpack-only global is gone.
src/server-dom.ts: window, document, navigator, localStorage, location, IntersectionObserver. Set with defineProperty and tolerant of read-only globals on Node 21+. localStorage is a Map-backed Storage.
server.ts- import domino from 'domino';- import { join } from 'path';+ import { installServerDom } from './src/server-dom';+ import { join } from 'node:path';+ import { fileURLToPath, pathToFileURL, URL as NodeURL } from 'node:url'; - const window = domino.createWindow(template);- (global as any).window = window;- (global as any).document = window.document;- (global as any).localStorage = window.localStorage;- (global as any).IntersectionObserver = class { observe() {} ... };+ installServerDom(template); - declare const __non_webpack_require__: NodeRequire;- if (moduleFilename === __filename || moduleFilename.includes('iisnode')) {+ if (isMainModule(import.meta.url) || process.argv[1]?.includes('iisnode')) {src/main.server.ts+ import 'zone.js/node';+ import './server-dom';+ import '@angular/localize/init';+ export { AppServerModule as default } from './app/app.server.module';
npm scripts and the white-label generator
Scripts
--prerender=false; prerender scripts set PRERENDER=true and let the configuration's block run.
serve:ssr
dev:ssr. --max_old_space_size=10048 unchanged everywhere.
scripts/generate-whitelabel.js, per tenant
buildTarget, browserTarget, serverTarget wherever they appear.
prerender.routesFile.
What broke: prerender found SSR bugs
- Symptom
- Public pages fail to prerender with an unhandled
Unauthorized. - Cause
- Prerender runs every route on Node with no session and no network.
ErrorInterceptorthrew on 401 and let status 0 propagate. - Fix
- Return
EMPTYfor 401, and for status 0 on the server. The request completes quietly.
- Symptom
getBoundingClientRect is not a functionduring prerender.- Cause
PinnedHorizontalScrollLayoutComponentguarded withtypeof window === 'undefined'. domino provides awindow, so the guard passed.- Fix
isPlatformBrowser(PLATFORM_ID), plus a check thatgetBoundingClientRectis a function.
error.interceptor.ts- import { BehaviorSubject, Observable, of, throwError } from 'rxjs';+ import { BehaviorSubject, EMPTY, Observable, of, throwError } from 'rxjs';+ if (!this.cs.isBrowser && err.status === Application.ZERO) {+ return EMPTY;+ }- return throwError(() => new Error('Unauthorized'));+ return EMPTY;pinned-horizontal-scroll-layout.component.ts- if (typeof window === 'undefined' || this.railMotionRafId !== null) {+ if (!this.isBrowser || this.railMotionRafId !== null) {- if (!wrapper) {+ if (!this.isBrowser || !wrapper || typeof wrapper.getBoundingClientRect !== 'function') {
A quiet step. Seven files, no code migrations, TypeScript unchanged. The builder work in 17 paid for this one. NgModules and PrimeNG 17.18 kept on purpose.
What changed
$localize runtime in implicitly.
index.html for prerendered output. Rendering from the wrong one double-bootstraps.
options constant that used it in WalletModule was unused.
create-plan.component.html. It sat on top of a reactive control that already owned the value.
| Library | From | To |
|---|---|---|
| @angular/ssr | 17.3 | 18.2 |
| @angular/material | 17.3 | 18.2 |
| @angular/fire | 17 | 18 |
| ngrx/store | 17 | 18 |
| ng-select | 12 | 13 |
| ng-bootstrap | 16 | 17 |
| ngx-bootstrap | 12 | 18 |
| ngx-toastr | 14 | 19 |
| ngx-mask | 16 | 18 |
| ngx-stripe | 17 | 18 |
| @zxing/ngx-scanner | 17 | 18 |
| angular-eslint | 17.5 | 18.4 |
What broke
- Symptom
DOMPurify.sanitize is not a functionon the server path.- Cause
AppComponentdoesimport DOMPurify from 'dompurify'. The package is CJS withmodule.exports = DOMPurify; in the 18 server bundle the default import resolved to the namespace object. Same class of bug as Quill and IVS, one version later.- Fix
- Namespace import, take
.default ?? ns, give it a narrow local type. Third time a CJS default import bit us; it became a rule.
src/app/app.component.ts- import DOMPurify from 'dompurify';+ import * as DOMPurifyNs from 'dompurify'; + type DomPurifySanitize = {+ sanitize: (dirty: string, cfg: { ALLOWED_TAGS: string[]; ALLOWED_ATTR: string[] }) => string;+ }; + const DOMPurify: DomPurifySanitize =+ (DOMPurifyNs as { default?: DomPurifySanitize }).default ??+ (DOMPurifyNs as DomPurifySanitize);
The standalone default flips. 704 files touched, nearly all of them one line: standalone: false. Still NgModules, still PrimeNG 17, still the application builder.
What changed
standalone to true. The migration writes the flag on every NgModule-declared class: 490 components, directives and pipes. The 166 that already said standalone: true lost the line.
Host header on SSR and on ng serve (security.allowedHosts in angular.json). The list is built from environment.websiteURL, webURL, original_web_url, plus *.topfan.dev, *.topfan.com and localhost.
ng serve has HMR on by default in 19 and already serves SSR. The opt-out script is the only one left.
| Library | From | To |
|---|---|---|
| @angular/material, cdk | 18.2 | 19.2 |
| @angular/fire | 18 | 19 |
| angular-fontawesome | 0.15 | 1.0 |
| ng-bootstrap | 17 | 18 |
| ng-select | 13 | 14 |
| ngrx/store | 18 | 19 |
| ngx-bootstrap | 18 | 19 |
| ngx-device-detector | 8 | 9 |
| ngx-infinite-scroll | 18 | 19 |
| ngx-mask | 18 | 19 |
| ngx-owl-carousel-o | 18 | 19 |
| @zxing/ngx-scanner | 18 | 19 |
| angular-eslint | 18.4 | 19.8 |
Follow-ups on 19
@types/node 18 to 20 with it.
tsconfig.json. Matches what Angular 19 targets.
buildspec-s3.yml (and three more)- nodejs: 18+ nodejs: 20functions/package.json- "node": "18"+ "node": "20"package.json- "@types/node": "^18.19.120",+ "@types/node": "^20.19.6",tsconfig.json- "es2018",+ "ES2022",ngsw-config.json+ "/polyfills.js",+ "/chunk-*.js"+ "/*.css",+ "!/chunk-*.js",+ "!/styles.*.css"
What to watch
- Risk
- A request whose
Hostheader is not inallowedHostsis rejected byCommonEngine. - Why
- The list is derived from the tenant's environment file. New white-label sites get their hosts from the generator; a hostname added by hand anywhere else does not.
- Rule
- Every hostname a tenant serves must be in its
environment.*URLs. Nothing else feeds the allow list.
- Risk
- A new component written the old way, no
standaloneflag, declared in an NgModule, fails to compile. - Why
- It is standalone by default now, and standalone classes cannot be declared in a module.
- Rule
- Either write
standalone: falseand declare it, or leave the flag off and import it where it is used. Not both.
Nothing broke on this step. The bundler and SSR work in 17 carried the risk.
server.ts- import { CommonEngine } from '@angular/ssr';+ import { CommonEngine } from '@angular/ssr/node';+ import { environment } from './src/environments/environment'; + function getSsrAllowedHosts(): string[] {+ const hosts = new Set(['localhost', '127.0.0.1', '::1', '*.topfan.dev', '*.topfan.com']);+ for (const value of [environment.websiteURL, environment.webURL, environment.original_web_url]) {+ const host = hostnameFromUrl(value);+ if (host) hosts.add(host);+ }+ return [...hosts];+ } - const commonEngine = new CommonEngine();+ const commonEngine = new CommonEngine({ allowedHosts: getSsrAllowedHosts() });any declared component, directive or pipe @Component({ selector: 'app-root', templateUrl: './app.component.html',+ standalone: false })
Twelve vendor stylesheets rode inside the global bundle on every page. Not an Angular change; the application builder made it cheap to do. Three passes: split them out, load each from the widget that draws it, make it survive SSR.
Three passes
- 12 entries in
stylesbecomebundleName+inject: false index.htmllinks all twelveAppComponentcallsload()for all 15 as a fallback- Service dedupes against existing
<link>s
index.htmllinks and theAppComponentcall removedLazyVendorCssDirectivematches vendor tags and classes- Players, dialogs, editor, toaster call
loadFor*()in their constructors
isPlatformBrowserguard dropped fromload()- The
<link>is appended to the server document and serializes into the HTML
End state on 19: first paint carries only our own scss and the white-label theme. A vendor sheet arrives the moment its first widget is constructed, on the server or in the browser, and never twice. Fifteen bundles then; twelve now. PrimeNG 20 has no theme CSS, and ng-select's sheet went back into styles for ordering. Next page.
Split the bundle
dist/, cached on its own.
LAZY_CSS_LOADER_CONSTANTS.hrefs map, twelve more keys.
index.html wrote them relative and the map writes them absolute.
angular.json, styles- "node_modules/primeng/resources/themes/saga-blue/theme.css",- "node_modules/primeng/resources/primeng.min.css",- "node_modules/primeicons/primeicons.css",+ {+ "input": "node_modules/primeng/resources/themes/saga-blue/theme.css",+ "bundleName": "lazy-primeng-theme",+ "inject": false+ },+ { ..., "bundleName": "lazy-primeng", "inject": false },+ { ..., "bundleName": "lazy-primeicons", "inject": false },lazy-css-loader.constants.ts+ 'lazy-primeng-theme': '/lazy-primeng-theme.css',+ 'lazy-primeng': '/lazy-primeng.css',+ 'lazy-primeicons': '/lazy-primeicons.css',lazy-css-loader.service.ts+ const hrefNoSlash = href.startsWith('/') ? href.slice(1) : href;+ if (this.document.querySelector(`link[href="${href}"]`) ||+ this.document.querySelector(`link[href="${hrefNoSlash}"]`)) {+ return;+ }
Load from the widget that needs it
| Trigger | Bundles |
|---|---|
owl-carousel-o | owl-carousel, owl-theme |
ng-select | ng-select |
ngx-intl-tel-input | intl-tel-input, flag-icons |
emoji-mart | emoji-mart |
quill-editor, .ql-editor, TextEditor | quill-snow, quill-emoji |
p-carousel, p-multiSelect, p-checkbox, .pi, CustomMultiselect | primeng-theme, primeng, primeicons |
[bsDatepicker], DateRangeFilter | bs-datepicker |
.flag-icon | flag-icons |
| ToasterService | toastr |
| VideoPlayer v1, v2, v3, modal, IvsPlayerService | videojs |
| SendTip, PostLikes, SharedDialog, CommentsSheet, ReelsMobileView, ImageOverlay | material-theme |
Tags and classes go through the directive, declared in SharedModule and DynamicFormBuilderModule, imported by the standalone AlertModal and CustomMultiselect. Things with no template hook, players, dialogs, the toaster, call the service directly.
lazy-vendor-css.directive.ts+ @Directive({+ selector: LAZY_CSS_LOADER_CONSTANTS.directiveSelector,+ standalone: true+ })+ export class LazyVendorCssDirective {+ constructor(el: ElementRef<HTMLElement>, loader: LazyCssLoaderService) {+ const tag = el.nativeElement.tagName.toLowerCase();+ if (tag === 'owl-carousel-o') { loader.loadForCarousel(); return; }+ ...+ }+ }lazy-css-loader.constants.ts+ directiveSelector: 'owl-carousel-o, ng-select, ngx-intl-tel-input, emoji-mart,+ quill-editor, [bsDatepicker], p-carousel, p-multiSelect, p-multiselect,+ p-checkbox, .pi, .flag-icon, .ql-editor',app.component.ts- this.lazyCssLoader.load('lazy-intl-tel-input', 'lazy-material-theme', ... 15 bundles);src/index.html- <link rel="stylesheet" href="lazy-primeng-theme.css">- ... 11 more
What broke
- Symptom
- Server-rendered pages arrived with widgets unstyled. They snapped into shape once the client bootstrapped.
- Cause
load()returned early when!isPlatformBrowser. The<link>never reached the server document, so the HTML shipped without it.- Fix
- Drop the guard.
DOCUMENTis the server DOM during render; a link appended there serializes into the page, and the browser sees it before any script runs.
- Symptom
- Multiselect overlay panel rendered with no PrimeNG styles.
- Cause
- Templates write
<p-multiSelect>. The directive selector saidp-multiselect. Angular matches element names as written. - Fix
- Both spellings in the selector, and
CustomMultiselectcallsloadForPrimeNG()itself so the overlay does not depend on the match.
- Symptom
- Text editor's undo and redo toolbar buttons lost their override styles.
- Cause
quill.snow.cssused to sit instyles.css, before component styles. Now it arrives after them. Equal specificity, last one wins.- Fix
button.ql-undoinstead of.ql-undo. One step up in specificity, order no longer matters.
lazy-css-loader.service.ts- import { Injectable, PLATFORM_ID, inject } from '@angular/core';- import { isPlatformBrowser, DOCUMENT } from '@angular/common';+ import { Injectable, inject } from '@angular/core';+ import { DOCUMENT } from '@angular/common'; - private platformId = inject(PLATFORM_ID); load(...bundles: LazyCssBundle[]): void {- if (!isPlatformBrowser(this.platformId)) return;text-editor.component.scss- .ql-undo,- .ql-redo {+ button.ql-undo,+ button.ql-redo {
The LTS stop. Angular itself was quiet: one import moved, one tsconfig flag. PrimeNG went 17 to 20 in the same step because 20 is the first major that dropped the CSS theme files we had just made lazy. That is where the work was.
What changed
tsconfig.json. Matches how esbuild already resolved packages; TypeScript now agrees with the build.
bundler resolution. The interop pattern from 17 stays; only the cast widened.
.component suffix by default. A schematics block keeps the old names so new files match the 700 existing ones.
@primeuix/themes, injected at runtime. There is no stylesheet to bundle, and the preset is not cosmetic: base styles size every widget through its dt() tokens. Next slide.
| Library | From | To |
|---|---|---|
| @angular/material, cdk | 19.2 | 20.2 |
| @angular/fire | 19 | 20 |
| angular-fontawesome | 1.0 | 2.0 |
| ng-bootstrap | 18 | 19 |
| ng-select | 14 | 20.4.4 |
| ngrx/store | 19 | 20 |
| ngx-bootstrap | 19 | 20 |
| ngx-device-detector | 9 | 10 |
| ngx-infinite-scroll, ngx-mask, ngx-owl-carousel-o | 19 | 20 |
| @zxing/ngx-scanner | 19 | 20 |
| primeng | 17.18 | 20.4 |
| @primeuix/themes | - | 1.2 |
| angular-eslint | 19.8 | 19.8, held |
PrimeNG 17 to 20
| 17 class | 20 class |
|---|---|
.p-multiselect-panel | .p-multiselect-overlay |
.p-multiselect-items-wrapper / -items / -item | .p-multiselect-list-container / -list / -option |
.p-multiselect-item.p-highlight | .p-multiselect-option-selected |
.p-checkbox.p-highlight | .p-checkbox-checked .p-checkbox-box |
.p-multiselect-token / -token-label / -token-icon | .p-multiselect-chip-item / -chip / -chip-icon |
.p-multiselect-trigger | .p-multiselect-dropdown |
.p-carousel-prev / -next | .p-carousel-prev-button / -next-button |
Renamed in both theme files, styles.scss, _page.scss, the custom multiselect, and seven feature scss files that reach into the overlay. lazy-primeng-theme and lazy-primeng deleted; loadForPrimeNG() fetches primeicons only. Lara supplies the dimensions and the light palette; colours and shapes stay in our scss.
app.module.ts+ import { providePrimeNG } from 'primeng/config';+ import * as LaraTheme from '@primeuix/themes/lara'; providers: [+ providePrimeNG({ theme: { preset: LaraTheme.default, options: { darkModeSelector: 'none' } } }),angular.json- { "input": ".../saga-blue/theme.css", "bundleName": "lazy-primeng-theme", ... },- { "input": ".../primeng.min.css", "bundleName": "lazy-primeng", ... },lazy-css-loader.service.ts- this.load('lazy-primeng-theme', 'lazy-primeng', 'lazy-primeicons');+ this.load('lazy-primeicons');custom-multiselect.component.html- <span class="p-multiselect-token-label">{{ item[optionLabel] }}</span>- <TimesCircleIcon class="p-multiselect-token-icon"></TimesCircleIcon>+ <span class="p-multiselect-chip">{{ item[optionLabel] }}</span>+ <svg data-p-icon="times-circle" class="p-multiselect-chip-icon"></svg>
What broke
- Symptom
- Custom multiselect opened with an unstyled search box and select-all row, and an empty strip above them.
- Cause
- On 17 our
pTemplate="header"rendered inside.p-multiselect-header, and every override hung off that class. On 20 the header template renders before that div, and the div itself still renders, empty, becauseshowHeaderdefaults to true. - Fix
[showHeader]="false". Our search and select-all get their own.custom-multiselect-headerwrapper, the footer its own.custom-multiselect-footer, and the scss hooks move onto classes we own. Lara's 1px overlay border, which saga never drew, cleared.
- Symptom
- Selected options and the select-all box lost their theme colour on white-label sites.
- Cause
- Overrides targeted
.p-highlight. 20 marks a checked box with.p-checkbox-checkedon the wrapper and colours the inner.p-checkbox-box. - Fix
- Rewrite the selector chain in both theme files.
--configThemeColorlands on the box, not the option.
- Symptom
- ng-select dropdown painted white on the dark theme, and spaces vanished from selected labels.
- Cause
- ng-select 20's
default.theme.csswas still a lazy bundle. It now arrived after our overrides and won on order. A leftoverdisplay: flexon.ng-value-labelcollapsed whitespace. - Fix
- Its sheet goes back into the global
stylesarray, ahead ofstyles.scss. Thelazy-ng-selectbundle,loadForNgSelect()and the flex rule are deleted. The one vendor sheet that must precede our css is not a lazy one. Pinned to 20.4.4: 20.4.0 also dropped asyncng-optionlists.
- Symptom
- Carousel arrows rendered as square Lara buttons and went dark with the OS. Owl arrows lost their round background.
- Cause
- 20 nests a
.p-buttoninside.p-carousel-prev-button; we styled the old outer class. Lara'sdarkModeSelectordefaults tosystem. The owl rule used[class*='owl-']and matched too much. - Fix
- Full arrow styles on wrapper and nested button in
styles.scssand the white-label theme. Owl selectors narrowed to.owl-prev/.owl-next.darkModeSelector: 'none'pins the light palette.
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.
What we do differently now
The version pages say what changed. This page says what we write from here on, and what 21 has not changed yet. Every rule traces back to a specific breakage.
Module shape and imports
| Rule | Traced to |
|---|---|
No require() in application code. The bundle is ESM; require is undefined at runtime and the build will not tell you. | Quill, CommonService |
CJS packages get one interop line at the import site: (mod as any).default ?? mod. Never dot into .default anywhere else. | Quill, IVS, DOMPurify |
Dynamic import() of a CJS package: detect the shape by a known export before unwrapping. | IVS |
Import cycles get removed, not hidden behind Injector.get and a lazy path. A leaf component that wants one boolean gets the primitive, not the fat service. | CommonService, InfoIcon, CircleBackgroundIcon |
Vendor CSS never goes in the global styles array or index.html. It is a bundleName + inject: false entry, a key in LAZY_CSS_LOADER_CONSTANTS.hrefs, and a loadFor*() call from the widget that renders it. One exception: a sheet our own scss overrides by order, not specificity, stays in styles ahead of styles.scss. | 12 bundles, 0 on first paint; ng-select is the exception |
| Overrides for a third-party component hang off a class we own, never the library's internal class. Wrap the slot and style the wrapper. | PrimeNG 20 renamed every multiselect and carousel class |
the one interop pattern+ import * as ModNs from 'some-cjs-package';+ const Mod = (ModNs as { default?: typeof ModNs }).default ?? ModNs; dynamic import of CJS+ const m = await import('some-cjs-package');+ const exportsObj = m?.default?.KNOWN_EXPORT ? m.default : m;+ const Client = exportsObj?.default ?? exportsObj; never- const { Thing } = require('../../path/to/thing');- const svc = this.injector.get(require('./svc').Svc);
Code that runs on the server
| Rule | Traced to |
|---|---|
isPlatformBrowser(PLATFORM_ID), never typeof window. domino gives the server a window, so the typeof check lies. | pinned scroll layout |
Layout and measurement APIs are browser-only even when window exists. Guard by platform, then by feature. | getBoundingClientRect |
Interceptors return EMPTY for a missing session or network on the server. Prerender has neither. | ErrorInterceptor |
New browser globals go in src/server-dom.ts via setGlobal. Nothing gets assigned onto global in server.ts. | server-dom.ts |
Run npm run prerender-qa before merging anything that touches a public route. The dev server never exercises this path. | both prerender bugs |
platform guard- if (typeof window === 'undefined') return;+ constructor(@Inject(PLATFORM_ID) private readonly platformId: object) {}+ private get isBrowser() { return isPlatformBrowser(this.platformId); }+ if (!this.isBrowser) return; feature guard on top+ if (!this.isBrowser || typeof el.getBoundingClientRect !== 'function') return; server-side http failure- return throwError(() => new Error('Unauthorized'));+ return EMPTY;
Build configuration
| Rule | Note |
|---|---|
One build target per project. No server, serve-ssr or prerender targets. | The generator deletes them anyway. |
These do not exist on the application builder and are silently ignored: vendorChunk, commonChunk, buildOptimizer, ngswConfigPath, main. | Use browser, polyfills: [], serviceWorker: "<path>". |
Prerender is a build flag. --prerender=false for plain builds, PRERENDER=true for prerender scripts. | No prerender target to run. |
Server-only packages go in externalDependencies. | domino, express, compression, helmet |
White-label projects are generated, never hand-edited. Change scripts/generate-whitelabel.js and regenerate. | Edits get overwritten. |
Node is pinned in .nvmrc, Dockerfile, the four buildspec-*.yml and functions/package.json. They move together. | CI sat on 18 for a while after the app moved. |
Every hostname a tenant serves lives in its environment.* URLs. That is the only source for SSR allowedHosts. | Unknown Host is rejected. |
angular.json, a new project's build target+ "builder": "@angular-devkit/build-angular:application",+ "options": {+ "outputPath": "dist/<project>",+ "browser": "src/main.ts",+ "server": "src/main.server.ts",+ "ssr": { "entry": "server.ts" },+ "polyfills": ["src/polyfills.ts", "@angular/localize/init"],+ "prerender": false,+ "externalDependencies": ["domino", "express", "compression", "helmet"]+ } - "vendorChunk": true,- "commonChunk": true,- "buildOptimizer": true,- "ngswConfigPath": "ngsw-config.json"
Framework and library APIs
common export still exists, deprecated.
@ means control flow.
dataKey, and option arrays are replaced, never mutated.
AppModule, and it stays: PrimeNG sizes widgets through its dt() tokens. Our scss overrides colour and shape through wrapper classes we own.
MatSliderChange is gone in Material 17+.
After 21: HTTP without the module
21 deprecates HttpClientModule. Not removed, but every ng update from here will nag about it, and the replacement is smaller. provideHttpClient() in the AppModule providers, two class interceptors become two functions.
| Rule | Traced to |
|---|---|
HTTP is provided once, in AppModule, with provideHttpClient(withFetch(), withInterceptors([...])). No component imports HttpClientModule. | FormSelectComponent had its own copy |
Interceptors are HttpInterceptorFn. Dependencies come from inject() at the top of the function, never from a constructor. | ErrorInterceptor, AuthInterceptor |
Array order is run order. errorInterceptor first so it sees the response before auth logs anyone out. | the old HTTP_INTERCEPTORS multi providers, same order |
withFetch() is the SSR transport. The skipInterceptor=true bypass and the server-side EMPTY return stay exactly where they were. | prerender, 401 on the server |
app.module.ts- HttpClientModule,- { provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },- { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },+ provideHttpClient(+ withFetch(),+ withInterceptors([errorInterceptor, authInterceptor])+ ),auth-int.interceptor.ts- @Injectable()- export class AuthInterceptor implements HttpInterceptor {- constructor(private readonly authService: AuthService, ...) {}- intercept(req: HttpRequest<any>, next: HttpHandler) {+ export const authInterceptor: HttpInterceptorFn = (req, next) => {+ const authService = inject(AuthService);+ const cs = inject(CommonService); if (req.urlWithParams.includes('skipInterceptor=true')) {- return next.handle(req);+ return next(req); }
After 21: Sass without @import
Angular 21 ships Dart Sass 1.97, which warns on every @import and says 3.0 will reject it. 110-plus component sheets did that, plus the globals. A silence list in angular.json was tried and taken out. Bootstrap keeps its own @import; not our file.
| Rule | Traced to |
|---|---|
A component sheet loads shared names with one line, @use 'tools' as *;. Never @import, never a relative path to _var.scss. | 138 files, 129 of them component sheets |
_tools.scss only forwards. It writes no CSS, or every component would emit a copy. | var, config-var, breakpoint-mixin, mixin |
src/assets/scss is on includePaths. That is why 'tools' resolves from any depth. | the same file had five different relative paths |
main.scss and its 24 partials compile as one unit. Do not mix @import and @use for the same partial inside it. Dart Sass stops the build. | a partial that used $link_color with no load rule of its own |
as * is deliberate. The old names keep working. Prefixing to var.$link_color would touch every call site for no change in output. | too many call sites to do it as the first move |
button.component.scss- @import '../../../assets/scss/var';- @import '../../../assets/scss/mixin';+ @use 'tools' as *; .btn { color: $link_color; }src/assets/scss/_tools.scss+ @forward 'var';+ @forward 'config-var';+ @forward 'breakpoint-mixin';+ @forward 'mixin';angular.json, stylePreprocessorOptions "includePaths": [ "sass",- "."+ ".",+ "src/assets/scss" ]ng serve, after+ 0 deprecation warnings from src/
After 21: library rules
| Rule | Traced to |
|---|---|
Close a modal with the BsModalRef injected into it. BsModalService is provided once, at the root. A feature module that lists it again gets its own copy and a dialog that will not close. | tag modal, camera modal. forum.module.ts still does this and only works by luck of the same instance. |
Tab headings from selectTab are display strings. Map them to the enum before comparing, and expect the event on init. | Messages tab |
Every MediaStream track you open, you stop. That includes the one from a permission check. | camera modal |
A standalone component imports what its template uses. NG8113 is a warning; fix it in the same change. | six components |
Firebase is the SDK, called from FirebaseService. No AngularFire. SDK callbacks that touch view state run inside NgZone.run(). | AngularFire's only 21 build pulled Firebase 12 |
A library with no release for our Angular gets a patch-package diff, committed, applied by postinstall. Not a fork, not an edit in node_modules. | ngx-intl-tel-input 15 |
tag.component.ts- import { BsModalService } from 'ngx-bootstrap/modal';+ import { BsModalRef } from 'ngx-bootstrap/modal';- private readonly modalService: BsModalService,+ private readonly modalRef: BsModalRef,- this.modalService.hide();+ this.modalRef.hide();dashboard.module.ts- providers: [BsModalService, ...]+ providers: [...]message-container.component.ts- this.active = event.heading;+ this.active = event.heading === MESSAGES_SMALL ? MESSAGES : event.heading;
How we run the next upgrade
- Versions and schematics only
- Nothing else in the change
- Every symbol
ng updatenamed - Schematics missed five files in 16
- One change per breakage
- Keeps the cause obvious
- Same framework version
- Never "new Angular" and "new builder" together
- Prerender
- The checks below, by hand
| Manual check | What it caught this time |
|---|---|
| Start a live stream | IVS CJS interop under esbuild |
| Open the text editor | require('quill') at runtime |
| Seek in the audio player | Material 17 slider events |
| Multiselect with duplicate options | PrimeNG 17 reference equality |
npm run prerender-qa | 401 and status 0 on the server, domino layout calls |
| Server-render a page with sanitized HTML | DOMPurify default import |
| Open the flag picker | lazy flag-icons css path |
| Open a multiselect on a white-label site | PrimeNG 20 class renames, header slot moved |
| Open an ng-select on the dark theme | vendor sheet loading after our overrides |
| Open an image carousel with the OS in dark mode | Lara dark-mode tokens, nested .p-button |
| Added on 21 | What it caught |
|---|---|
| Tag someone in a post, close the dialog | second BsModalService |
| Open Messages on a fresh load | selectTab fires on init |
| Livestream preview, then leave | camera track never released |
| Scroll a feed, resize the window | 49 listeners taking $event |
Read the ng serve log to the end | Sass @import, NG8113 |
Dynamic imports and Material or PrimeNG components fail at runtime, behind a user action. Nothing in the build catches them. A library that renames its CSS classes breaks silently. A service provided twice is the same kind of silent. Screenshot the affected components before and after.
The build is 21. The source is not.
package.json says 21.2. The app still boots with bootstrapModule(AppModule), the shape it had on 12. Six majors of upgrade, almost none of the code Angular would write today. That was the plan. It is still worth seeing in numbers.
| Practice | This repo | Angular 21 |
|---|---|---|
| Bootstrap | bootstrapModule(AppModule) | bootstrapApplication |
| Components | 533 standalone: false, 17 standalone | standalone by default |
| Templates | 6,148 *ngIf, 763 *ngFor | @if, @for, since 17 |
| Signals | 0 signal(), 0 computed() | the state model |
| Inputs, queries | 1,412 @Input, 597 @ViewChild | input(), viewChild() |
| Router | RouterModule.forRoot, class guards | provideRouter, CanActivateFn |
| SSR | CommonEngine, no hydration | provideClientHydration(withEventReplay()) |
| Change detection | 92 of 666 OnPush, zone.js | OnPush, signals, zoneless |
| Unsubscribe | 1,142 takeUntil(destroy$) | takeUntilDestroyed() |
| Forms | 747 UntypedForm* | typed FormGroup |
Already moved
- The
applicationbuilder, since 17. provideZoneChangeDetection(). A patch so NgModule bootstrap still works on 21, not a modernisation.provideHttpClientand two functional interceptors.- One functional guard,
promotionalAuthGuard. The other eight guard files are still classes. - Vendor CSS split into lazy bundles.
- Sass on
@use. Zero@importinsrc/.
Counts from src/ on 22 September 2026. profile.component.html alone has 320 *ngIf. That file, and message-container at 144, are why a mechanical control-flow migration needs a human reading the diff. ng update tried it on the way to 21 and we reverted.
Deliberately left alone, and the order after this
| Thing | Status | Why |
|---|---|---|
| NgModules | kept | 533 declared components, a working lazy-route tree. Its own project, and the first big one. |
| zone.js | kept | Stable in 21, and ng new asks for it. 46 files touch NgZone, 524 call detectChanges(). Signals and OnPush first, or it breaks quietly. |
| quill 1.3.7 | kept | The 21-era ngx-quill wants quill 2. The editor works, but 1.3.7 has XSS history. Not free to leave. |
ng-ivs, ngx-audio-player, ngx-stripe | out of range | Peer Angular 12, Material 17, Angular 18. Ran on 20 with --force, same on 21. |
architect.serverless | legacy builder | build-angular:server with tsconfig.server.json. Builds, not on the main path. |
| Angular 22 | not yet | Wants Node 24.15+ and TypeScript 6. 21 holds until June 2027. A later branch. |
Order of work, when we pick it up
provideClientHydration(withEventReplay()). We render on the server and then rebuild the tree in the browser. Hydration is the reason to keep SSR.provideRouter(routes, withComponentInputBinding()). Convert the class guards to functions.- One leaf feature to standalone routes. Learn on something small.
@if/@for, file by file, withtrack. Wallet and feed first, profile last.@deferaround video, Stripe, Quill, Highcharts, emoji-mart.ngSrcon banner and merch images.input(),output(),viewChild(). ThentakeUntilDestroyed()for the 1,142destroy$pipes.- Typed forms as each form is touched. Vitest before the first spec lands, since there is nothing to port.