Migration

Angular 17 to 18

The working tree is on Angular 18.2 with NgModules, the application builder, and custom Express SSR kept. PrimeNG stays 17. Ship the source hunks below. Do not ship env or prefetch artifacts.

Implementation

Node 20.19.6, TypeScript 5.4.5, and Zone 0.14.10 already met Angular 18. Those were left alone. Angular 18.2 accepts TypeScript 5.4 and 5.5 and rejects 5.6.

ng update @angular/core@18 @angular/cli@18 then @angular/material@18, with 10GB old-space. The application-builder schematic was refused. The HttpClient schematic was refused and reverted if it ran. server.ts was not replaced by CLI output. Hand edits only for what 18 actually broke.

Libraries that publish an Angular 18 line were bumped. PrimeNG, ngx-quill, and ngx-intl-tel-input stayed on the current majors. @khajegan/ngx-audio-player stayed 17.1.0. @cotufa82/ng-ivs stayed as a legacy peer.

Compile used --configuration=development and qa with --prerender=false. Production proof used npm run prerender-all-qa. npm run build-dev was not used; environment.dev.ts is still missing. Docker was not used.

Kept on purpose

Verification diffs

Against HEAD on 15 Sep 2026, these are the upgrade source hunks: create-plan, server.ts, app.component.ts, wallet.module.ts, and angular.json. If git shows more than these plus package.json / package-lock.json, something extra is in the tree.

create-plan Free DMs radio

Angular 18 rejects banana-box on AbstractControl.value. The same radio already has formControlName="free_dms_count", so those two lines were not the live binding. Create Plan enablement did not change. Empty 1 Month Total Price with the card collapsed still disables the button. That is old checkFormDisable behavior, not an 18 regression.

A scan of src/**/*.html found no other [(ngModel)] on .get(...).value, .controls[...].value, or any .value getter, and no other formControlName plus ngModel pair. Remaining .get('x').value uses are one-way (*ngIf, ngClass, interpolation, [value], [disabled]). Those stay.

diff --git a/src/app/modules/monetize/components/create-plan/create-plan.component.html b/src/app/modules/monetize/components/create-plan/create-plan.component.html
@@ -2003,8 +2003,6 @@
                   >
                     <input
                       [value]="dm.value"
-                    [(ngModel)]="createPlanForm.get('free_dms_count').value"
-                    [ngModelOptions]="{ standalone: true }"
                       (change)="freeDirectMessagesChange(dm.value)"
                       type="radio"
                       formControlName="free_dms_count"

SSR shell path

Angular 18 writes the client shell as index.csr.html when prerender is off. Prerendered routes still use index.html. Express must prefer the CSR file for installServerDom and CommonEngine.documentFilePath or live SSR serves the wrong document.

diff --git a/server.ts b/server.ts
+function getBrowserIndexHtml(distFolder: string): string {
+  const csrIndex = join(distFolder, 'index.csr.html');
+  if (fs.existsSync(csrIndex)) {
+    return csrIndex;
+  }
+  return join(distFolder, 'index.html');
+}

   export function app(): express.Express {
     const distFolder = getDistFolder();
-  const template = fs.readFileSync(join(distFolder, 'index.html')).toString();
+  const indexHtml = getBrowserIndexHtml(distFolder);
+  const template = fs.readFileSync(indexHtml).toString();
     installServerDom(template);

     return commonEngine.render({
       bootstrap: AppServerModule,
-        documentFilePath: join(distFolder, 'index.html'),
+        documentFilePath: indexHtml,
       url: `${protocol}://${headers.host}${originalUrl}`,

DOMPurify under esbuild

The Angular 18 esbuild pipeline does not accept the default DOMPurify import. Namespace import plus .default fallback keeps sanitize working on client and server.

diff --git a/src/app/app.component.ts b/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);

ngx-mask 18

ngx-mask 18 removed IConfig. The unused wallet options export was dropped. provideNgxMask() is unchanged.

diff --git a/src/app/modules/wallet/wallet.module.ts b/src/app/modules/wallet/wallet.module.ts
-import { NgxMaskDirective, NgxMaskPipe, provideNgxMask, IConfig } from 'ngx-mask';
+import { NgxMaskDirective, NgxMaskPipe, provideNgxMask } from 'ngx-mask';

-export const options: Partial<IConfig> = {
-  thousandSeparator: "'"
-};

angular.json localize polyfill

CLI 18 wants @angular/localize/init first in the TopFans polyfills array. White-label projects copy that from TopFans. src/polyfills.ts already imported it; the array entry is the CLI contract. This is the only angular.json hunk. Other projects still use the string src/polyfills.ts polyfill option.

diff --git a/angular.json b/angular.json
index c82ccd58e5..6162bc74b4 100644
--- a/angular.json
+++ b/angular.json
@@ -41,6 +41,7 @@
             "outputPath": "dist/TopFans",
             "index": "src/index.html",
             "polyfills": [
+              "@angular/localize/init",
               "src/polyfills.ts"
             ],
             "tsConfig": "tsconfig.app.json",

Package moves

Lockfile belongs with package.json. TypeScript and Zone did not move.

Package 17 18 tree
core / compiler / animations / forms / router / material / CDK 17.3.12 / Material 17.3.10 18.2.14
CLI / SSR / PWA / build-angular / architect 17.3.17 18.2.21
Angular ESLint 17.5.3 18.4.3
@angular/fire 17.1.0 18.0.1
@ngrx/store 17.2.0 18.1.1. Store only. No effects.
ngx-bootstrap 12.0.0 18.1.3
@ng-bootstrap/ng-bootstrap 16.0.0 17.0.1
@ng-select/ng-select 12.0.7 13.9.1
ngx-toastr 14.3.0 19.1.0. Toast is OnPush now.
ngx-mask 16.4.2 18.0.4
ngx-stripe 17.2.0 18.1.0
fontawesome / owl / infinite-scroll / device-detector / zxing scanner 0.14.1 / 17 / 17 / 7 / 17 0.15.0 / 18 / 18 / 8 / 18
PrimeNG 17.18.15 Kept 17.18.15
ngx-quill / ngx-intl-tel-input / audio player 16.2.1 / 15 / 17.1.0 Kept. Quill 2 and PrimeNG 18 are later PRs

package.json also adds prerender-existing. That script is not required for Angular 18. Drop it from the PR unless you want the helper.

Do not ship

Path Why
src/environments/environment.ts Local env. Committed files must not pick up machine URLs or secrets.
prerender.routes.txt and src/app/constants/*generated* / marketing page data Prefetch / generate-static output from prerender-all-qa. Restore before commit.
angular-18-upgrade.html, create-plan-ngmodel-change.html Local plan files. Not app runtime.

Already proven

Check Result
development build and qa with --prerender=false Passed
npm run prerender-all-qa Passed. Both index.html and index.csr.html exist
Live SSR on dist/TopFans/server/server.mjs Unknown route 404. Trailing slash 301. ?key= rendered. Shell is index.csr.html
One QA white-label tenant doug-h cloned, qa build succeeded, theme #8b5cf6, login branded. Generator artifacts restored after smoke
17 product checks on 18 /music to /music-artists. Wallet bank-details still redirects. Login, forgot-password, overlays render. Live Stripe checkout was not run
Create Plan radios Compile unblocked. Selection still goes through formControlName. Button disable with empty 1 Month price is unchanged
ng lint / ng test Not a 17 gate. Lint OOMs. No spec files. Do not claim they passed

Not in this PR

Before merge