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
- NgModules. No standalone rewrite. No
standalone: falseflags (those are v19). @angular-devkit/build-angular:application. Already inangular.json.- Custom Express in
server.ts. White-label still picked fromdist/. HttpClientModuleinAppModuleandFormSelectComponent. Interceptors still useHTTP_INTERCEPTORS.- Hydration off. No
provideClientHydration. NongZoneEventCoalescing. - Material 2 with prebuilt
indigo-pink. - PrimeNG 17.18.15 and
saga-blue. PrimeNG 18 dropped that theme and wantsprovidePrimeNG. functions/and the webpackserverlesstarget untouched. Live path isserve:ssr/ S3 prerender.
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.
> <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.
+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.
-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.
-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.
"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
- PrimeNG 18 theme rewrite
- Quill 2 / ngx-quill 18
- Update of
@cotufa82/ng-ivs - Hydration,
event-dispatch,provideHttpClient - Material 3
- Standalone rewrite
functions/and webpack serverless- Recreating
environment.dev.ts
Before merge
- Restore prefetch-generated files so the PR diff is only the hunks above plus packages.
- Leave
environment.tsout of the commit. - Decide whether
prerender-existingstays. - QA: Create Plan still needs 1 Month opened and Total Price filled. That is not an 18 bug.
- Live Stripe, logged-in audio, and logged-in video were not exercised.