Four moves Angular does not automate. Each is independently shippable. Verified 2026-09 against https://angular.dev/guide/zoneless, https://angular.dev/guide/testing/migrating-to-vitest, https://angular.dev/guide/animations/migration and https://angular.dev/guide/forms/signals/overview.
1. zone.js → zoneless
Why: zone.js patches every async browser API so Angular can run change detection after each one. That means change detection after every setTimeout, every fetch and every mousemove — most of which changed nothing. Removing it also removes ~15 kB and a whole class of "why did this run" confusion.
Steps
- v20: add
provideZonelessChangeDetection()toapp.config.ts. v21+: zoneless is already the default — instead, grep forprovideZoneChangeDetection(and delete it, because it overrides the default. - Remove
zone.js(andzone.js/testing) from thepolyfillsarray inangular.json. - Run the app and click through every feature. Unit tests will not catch what breaks here.
- Fix what stops updating. In zoneless mode Angular is notified by: a signal read in a template being written, a template or host listener firing,
ChangeDetectorRef.markForCheck()(whichAsyncPipecalls for you), andComponentRef.setInput. Anything else — a third-party callback, a rawaddEventListener, a promise resolution writing a plain field — updates nothing until one of those happens. - The fix for a third-party callback is a signal write or
markForCheck(), not putting zone.js back. - Delete every
NgZone.runOutsideAngular(...)andngZone.run(...). Outside a zone they are no-ops with a misleading name, and they hide the fact that the notification now has to be explicit.
OnPush is recommended but not required: a component on the default strategy still works if it notifies through one of the mechanisms above. This matters when a dependency you do not control ships default-strategy components.
Verification: a click-through of every feature, with particular attention to anything driven by a setTimeout, a WebSocket, a third-party widget, or a non-Angular event listener.
2. Karma → Vitest
Why: Vitest is the CLI default from v21, is substantially faster on most suites, and shares tooling with the rest of the JavaScript ecosystem. Karma itself is unmaintained, though Angular still supports the builder — so this is a "when it is worth it" move, not an emergency.
Steps
npm install --save-dev vitest jsdom
Point the test target at the new builder in angular.json:
"test": {
"builder": "@angular/build:unit-test",
"options": { "include": ["src/**/*.spec.ts"], "setupFiles": ["src/testing/setup.ts"] }
}
tsConfig defaults to tsconfig.spec.json and buildTarget to ::development; set them only if your project differs.
ng generate @schematics/angular:refactor-jasmine-vitest
The schematic converts spec syntax: fit/fdescribe → it.only/describe.only, xit/xdescribe → it.skip/describe.skip, spyOn → vi.spyOn, jasmine.createSpy → vi.fn, jasmine.objectContaining → expect.objectContaining, jasmine.any → expect.any, and the beforeAll/beforeEach/afterAll/afterEach hooks.
It explicitly does not: install dependencies, change angular.json, or remove karma.conf.js and test.ts. Those are yours.
What the schematic cannot do
fakeAsync/tickdepend onzone.js/testing. Once zone.js is gone they throw "zone-testing.js is needed". Replace withvi.useFakeTimers()+vi.advanceTimersByTime(n), or withawait fixture.whenStable()where the test was really waiting for Angular rather than for a timer.donecallbacks →async/await.- Karma-specific config (custom launchers, proxies, frameworks) has no direct equivalent; decide per item whether it is still needed.
Sequence that keeps CI green: add the Vitest target alongside Karma and run both; convert one feature's specs; when the Vitest run covers everything, remove the Karma target, karma.conf.js, test.ts and the jasmine/karma dependencies in one PR.
3. @angular/animations → CSS
Why: the package is deprecated. Native CSS animations run on the compositor where the browser can hardware-accelerate them, and they drop a dependency and a provider from the bundle.
| Old | New |
|---|---|
:enter transition / trigger with a void => * state |
animate.enter="<class>" on the element inside @if/@for |
:leave transition / * => void |
animate.leave="<class>" — Angular keeps the element until the animation ends |
State machine (state('open', style({...}))) |
A class binding plus a CSS transition: [class.open]="isOpen()" |
query + stagger |
CSS animation-delay computed from $index, or a small @keyframes per position |
provideAnimations() / provideNoopAnimations() |
Remove; provideNoopAnimations is itself slated for removal |
@if (isShown()) {
<div class="panel" animate.enter="panel-enter" animate.leave="panel-leave">…</div>
}
@keyframes slide-in { from { transform: translateY(8px); opacity: 0; } }
.panel-enter { animation: slide-in 200ms ease-out; }
.panel-leave { animation: slide-in 150ms ease-in reverse; }
@media (prefers-reduced-motion: reduce) {
.panel-enter, .panel-leave { animation: none; }
}
Add the prefers-reduced-motion block while you are there — the old @angular/animations code almost certainly did not have it.
4. Reactive Forms → Signal Forms
Only on v22+, and only for forms you are already changing. Typed Reactive Forms are stable and not deprecated; a working reactive form is not debt. Angular's own guidance is to prioritise Signal Forms for new forms, which is a different statement from "rewrite the old ones".
| Reactive | Signal Forms |
|---|---|
FormGroup / FormBuilder.group({...}) |
A signal() holding the model object, passed to form(model, schema) |
FormControl<string>(…, { nonNullable: true }) |
A property on the model signal; the type comes from the model |
Validators.required, Validators.email |
required(path), email(path) inside the schema function |
formControlName="email" |
[formField]="f.email" |
control.invalid && control.touched |
f.email().invalid() && f.email().touched() |
control.errors |
f.email().errors() |
FormArray |
applyEach(path, …) in the schema |
| Async validator hitting the server | validateHttp(...) in the schema |
onSubmit() handling the HTTP call and server errors |
submit(f, { action: async (f) => … }) returning field errors or undefined |
Never mix the two APIs inside one form: two sources of truth for one set of fields produces state that disagrees with itself and is untestable. A component can hold one reactive form and one signal form side by side; a single form cannot be half-migrated.
Field-by-field recipes and the full schema API: signal-forms.md in ../../angular-development/references/.
5. Smaller one-line moves
| Old | New | Note |
|---|---|---|
afterRender(...) |
afterEveryRender(...) |
Renamed in v20, no alias — code using the old name does not compile |
AfterRenderPhase.Read |
afterNextRender({ read: () => … }) |
The enum is gone; phases are object keys |
TestBed.flushEffects() |
TestBed.tick() |
Deprecated |
withIncrementalHydration() |
Remove | On by default in provideClientHydration() since v22 |
withEventReplay() |
Remove on v22 | Incremental hydration enables it |
APP_INITIALIZER |
provideAppInitializer(fn) |
Token deprecated |
RouterTestingModule |
RouterModule + provideLocationMocks() |
Schematic: @angular/core:router-testing-module-migration |
standalone: true on a new component/pipe |
Delete the property | Standalone is the default since v19 |
withFetch() on provideHttpClient |
Remove on v22 | fetch is the default backend |