TopFans web frontend. Four majors, two bundlers, one SSR engine swap. NgModules, PrimeNG 17 and zone.js stay where they are.
What moved
| Thing | Before | After |
|---|---|---|
| Angular | 15.2.10 | 19.2.25 |
| TypeScript | 4.9.5 | 5.8.3 |
| zone.js | 0.11 | 0.15 |
| rxjs | 7.5 | 7.8 |
| Node | node:20-slim, unpinned | 20.19.6, pinned |
| 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 | 17.18, held |
| Angular Material | 15.2 | 19.2 |
| Component flag | implicit | standalone: false, explicit |
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
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.
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 | 18.20.8 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:18.20.8-slim.nvmrc (new)+ 18.20.8angular.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 | 18.20.8 | 20.19.6, the 18 line 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 gets an id ofidentity:occurrence, so duplicates are distinct. Sync back to the parent andmarkAsDirty;onBlurandonPanelHidemark touched;statusChangesmirrors disabled. Three passes to get all of it.
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 internalIdsByOption = new Map<unknown, string[]>(); + const identity = this.getOptionIdentity(option) || `index:${index}`;+ const occurrence = identityOccurrences.get(identity) || 0;+ const internalId = `${identity}:${occurrence}`;
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 |
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 })
What we do differently now
The previous pages say what changed. This page says what we write from here on. 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 |
Lazy stylesheets go through the assets array and LAZY_CSS_LOADER_CONSTANTS.hrefs. | flag-icons |
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 and Dockerfile. They move together. | Both changed twice on this path. |
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
@ means control flow.
MatSliderChange is gone in Material 17+.
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 |
Dynamic imports and Material or PrimeNG components fail at runtime, behind a user action. Nothing in the build catches them. Screenshot the affected components before and after.
Deliberately left alone
| Thing | Status | Why |
|---|---|---|
| NgModules | kept | Hundreds of declared components and a working lazy-route tree. Standalone buys nothing until we want to drop modules for real. Its own project. |
| PrimeNG | 17.18 | 18 replaces theming with design tokens and drops the CSS theme files we lazy-load. A styling migration, not an Angular one. |
| zone.js | kept | Zoneless is still experimental in 19. Nothing here depends on it. |
Signals, control flow, inject() | new code only | Use them where they read better. Do not convert existing files as a side quest. |
architect.serverless | legacy builder | Still build-angular:server with tsconfig.server.json. Builds, not on the main path. Next to fold into the application builder. |
Where that leaves the repo
| Angular | 19.2 |
| Builder | application |
| SSR | @angular/ssr CommonEngine, ESM server |
| Component model | NgModules, standalone: false explicit |
| PrimeNG | 17.18 |
| Node | 20.19.6 |
| TypeScript | 5.8 |
| Leftover | tsconfig.server.json, serverless target |