Upgrading Our Angular Projects to v22

Upgrading Our Angular Projects to v22

frontend Published 9 min read

Angular 22 landed in June, and I had 2 projects that needed to come along. task-tracker-ngrx is the task board behind my NgRx Signal Store articles, the method-based one and the event-driven one, and it was sitting on v20. angular-optimization is the Pokémon app I use to show Angular performance techniques, and it was still on v19.

Long story short, the migrations themselves were the easy part. What stopped us first was never our own code. Here's what we ran into, in the order we hit it:

  1. Before ng update, a Node.js floor and peer ranges that block the upgrade before a single migration runs
  2. One major at a time, and what each step rewrote for us
  3. What the migrations leave behind, from OnPush by default to providers that are now defaults
  4. SSR, where an unknown host now gets a 400 instead of a fallback
  5. Going zoneless, the optional step that paid for itself

Before Running ng update

The Node.js Floor

Angular 22 raises the minimum Node.js version, and the range is stricter than it looks:

"engines": {
  "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}

So Node 20 is out, Node 22 below 22.22.3 is out, and so is anything on 24 below 24.15.0. The Volta pin and the CI matrix of the task board were both on 24.7.0, which looks recent enough and isn't, and angular-optimization was still pinned to 20.18.0. We raised both pins and both CI configs before anything else, otherwise the upgrade fails on engines before it fails on anything interesting.

Peer Ranges That Block the Upgrade

ng update checks peer ranges before it migrates anything, so any package in the tree that caps Angular at the old major stops it right there. We hit 2 of them.

  • The test stack. Both projects run Vitest through Analog, and Analog 1.x caps its @angular/build peer at ^20. Moving to Analog 2, Vitest 4 and Vite 7 while still on the old Angular version cleared the way, and the 41 tests of the task board stayed green on v20 before we touched Angular itself.
  • NgRx Signals. @ngrx/signals 22 only shipped on 24 August, almost 3 months after Angular 22, and the 21 line peers on @angular/core ^21. So until last week, a Signal Store app on Angular 22 was running a pair NgRx didn't support. If your state library isn't in step with Angular yet, that's the one to check before anything else.

💡 Upgrade whatever pins Angular's peers first, on the old major, so the Angular upgrade commit only contains the Angular upgrade.

TypeScript Goes to 6.0, Not to Latest

@angular/build 22 peers typescript: ">=6.0 <6.1", while typescript@latest on npm has been 7.0.2 since early July. A blind bump to latest therefore puts us outside the supported range, so we pin it:

pnpm add -D typescript@~6.0.3

The lint stack needs a look too, since typescript-eslint below 8.58 caps TypeScript under 6.0 and refuses to run against it.

One Major at a Time

ng update won't skip majors, and that's what we want anyway, since every major ships its own migrations. For the task board, which started on v20, it looked like this:

npx ng update @angular/cli@21 @angular/core@21
npx ng update @ngrx/signals@21
npx ng update @angular/cli@22 @angular/core@22
npx ng update @ngrx/signals@22

What each step did:

  1. Angular 21 ran its migrations and, while at it, made an old mistake visible: main.ts inlined its own providers instead of using appConfig, so the browser build never got provideClientHydration(withEventReplay()) while the server build did. Both entry points bootstrap from the one config now.
  2. NgRx Signals 21 renamed withEffects to withEventHandlers. The old name was removed rather than aliased, so v20 store code won't compile on v21, and the schematic rewrote both call sites for us. That's a good reason to run ng update rather than editing package.json by hand.
  3. Angular 22, with the changes the rest of this post is about.
  4. NgRx Signals 22 last, back in step with Angular.

angular-optimization started on v19, so it took 3 rounds, and we committed each major separately. The v20 round moved its server config to provideServerRendering(withRoutes(serverRoutes)) and passed a BootstrapContext through main.server.ts, both by migration. When something breaks later, a commit per major means it points at one version instead of three.

What the Migrations Leave Behind

OnPush Is the Default, and Eager Is Everywhere

In v22 a component without a changeDetection property is OnPush. To keep existing apps behaving as before, the migration stamps the old behaviour onto every component that didn't declare a strategy:

@Component({
  selector: 'app-pokedex',
  // …
  changeDetection: ChangeDetectionStrategy.Eager,
})

Eager is the new name for what used to be Default, and in angular-optimization the migration added it to 13 components. angular-eslint 22.1 then made prefer-on-push-component-change-detection part of its recommended config, and the rule now reports exactly one thing: components that opt out of OnPush. So right after the migration, our linter flagged every line the migration had just written.

Keeping Eager means fighting the linter, so we removed it. In the task board that was all it took, because the form, the columns and the buttons all change state through template event bindings, and those mark the component dirty on their own. angular-optimization needed one more step, since some of its state changes outside any template binding:

public cols = signal(1);
public rowHeight = signal('380px');

ngOnInit() {
  this.breakpointObserver
    .observe([Breakpoints.XSmall, Breakpoints.Small /* … */])
    .subscribe(result => {
      if (result.breakpoints[Breakpoints.XSmall]) {
        this.cols.set(this.gridByBreakpoint.xs);
        this.rowHeight.set('250px');
      }
      // …
    });
}

cols and rowHeight used to be plain fields assigned inside that subscription. With Eager and zone.js, the next change detection pass picked them up. Under OnPush nothing marks the component dirty when a subscription fires, so the grid would keep its first layout. As signals, the template reads them and Angular knows exactly when they change. The form toggle on the same page got the same treatment.

💡 Look for fields assigned inside subscribe, timers or listeners you register by hand. Those are the ones OnPush stops picking up.

Providers That Are Now Defaults

Incremental hydration and the fetch backend are defaults in v22, and withFetch is deprecated outright. The app config of angular-optimization had all of this:

provideClientHydration(withIncrementalHydration()),
provideHttpClient(withFetch()),
provideAnimationsAsync(),

and now it's down to:

// Incremental hydration and the fetch backend are the defaults since v22.
provideClientHydration(),
provideHttpClient(),

provideAnimationsAsync() went too, together with the @angular/animations package, since Angular Material no longer needs it. We also dropped @angular/platform-browser-dynamic, which has been deprecated since v20 and was only still there for the test setup.

SSR

An Unknown Host Now Gets a 400

Angular's SSR engine checks the Host header against an allow list. Before v22, a request from a host it didn't know about quietly fell back to client-side rendering. From v22 it gets a 400 Bad Request, so the first time we ran the task board's SSR build locally, every request answered with:

Header "host" with value "localhost:4000" is not allowed.

until localhost was on the list in angular.json:

"security": {
  "allowedHosts": ["localhost"]
}

Please use that one with care on deploy. The real hostname needs to be in there too, or set through NG_ALLOWED_HOSTS, otherwise production answers 400 the same way, and nothing falls back anymore.

On Netlify: Runtime v4

angular-optimization deploys to Netlify, and the upgrade needed @netlify/angular-runtime v4. v4 removes the @netlify/angular-runtime/context import our old Express-based server.ts used, so the server entry became the runtime's App Engine handler:

import { AngularAppEngine, createRequestHandler } from '@angular/ssr';
import {
  getAllowedHosts,
  getContext,
  getTrustProxyHeaders,
} from '@netlify/angular-runtime/app-engine.js';

const angularAppEngine = new AngularAppEngine({
  allowedHosts: getAllowedHosts(),
  trustProxyHeaders: getTrustProxyHeaders(),
});

export async function netlifyAppEngineHandler(request: Request): Promise<Response> {
  const result = await angularAppEngine.handle(request, getContext());
  return result || new Response('Not found', { status: 404 });
}

export const reqHandler = createRequestHandler(netlifyAppEngineHandler);

3 things changed with it:

  1. Netlify runs this module as an Edge Function on Deno, so Express and anything Node-only had to go, and with them the serve:ssr script, which by then only served static files.
  2. The runtime hands over the host allow list and the proxy-header setting, so the 400 from the previous section is covered on deploy.
  3. reqHandler keeps ng serve rendering on the server locally, through the same handler.

Going Zoneless

Zoneless has been the default for new projects since v21, and once every component was fine under OnPush, removing zone.js from angular-optimization was mostly deletion: the polyfill entry in angular.json, the package, and provideZoneChangeDetection() in the app config. The initial bundle went from 669 kB to 626 kB, which is below the 640 kB it had before the upgrade started.

The tests needed their own changes:

// jsdom does not implement IntersectionObserver, which `@defer (on viewport)` relies on.
class IntersectionObserverStub {
  observe(): void {}
  unobserve(): void {}
  disconnect(): void {}
  takeRecords(): IntersectionObserverEntry[] {
    return [];
  }
}
globalThis.IntersectionObserver ??=
  IntersectionObserverStub as unknown as typeof IntersectionObserver;

setupTestBed({ zoneless: true });

Analog's setupTestBed({ zoneless: true }) replaces the zone-based test setup, and the specs that used fakeAsync, which needs zone.js, moved to whenStable(). The stub is there because jsdom has no IntersectionObserver, and the @defer (on viewport) blocks on the pages need one.

Run It in a Browser

Build, lint and tests all passed on both projects, and we still drove them in a browser, with Playwright against ng serve with SSR on and the Pokémon API mocked. In angular-optimization that meant 13 checks, from server rendering and the deferred Pokémon list to the dialogs and the mobile layout, with no runtime or hydration errors.

What to Watch Out For

  1. Raise Node.js first, to 22.22.3, 24.15.0 or 26 and above, in the version pin and in the CI matrix.
  2. Move anything that pins Angular's peers on the old major, before ng update: the test stack, the lint stack, and NgRx.
  3. Pin TypeScript to ~6.0, not to latest.
  4. Run ng update one major at a time, and commit each one on its own.
  5. Decide what to do with Eager. Keep it and silence the lint rule, or remove it and move state that changes outside templates to signals.
  6. Put every real hostname into allowedHosts before deploying SSR, since there's no client-side fallback anymore.
  7. On Netlify, move to runtime v4 and its App Engine handler.
  8. Drive the app in a browser before calling it done.

If you'd rather see Angular built up end to end with a real backend behind it, the second edition of Spring Boot and Angular, which I co-authored with Ahmad Gohar, comes out in September. It pairs Spring Boot microservices with Angular v22 and NgRx Signals 22, the same versions this post ends on.

Cover of Spring Boot and Angular, second edition, by Ahmad Gohar and Dimitrios Kyriakakis

Second edition, Spring Boot microservices with Angular v22 and NgRx Signals 22.

You can pre-order it on Amazon.

Resources