# The migrations with no schematic

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**

1. **v20:** add `provideZonelessChangeDetection()` to `app.config.ts`. **v21+:** zoneless is already the default — instead, grep for `provideZoneChangeDetection(` and delete it, because it overrides the default.
2. Remove `zone.js` (and `zone.js/testing`) from the `polyfills` array in `angular.json`.
3. Run the app and click through every feature. Unit tests will not catch what breaks here.
4. 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()` (which `AsyncPipe` calls for you), and `ComponentRef.setInput`. Anything else — a third-party callback, a raw `addEventListener`, a promise resolution writing a plain field — updates nothing until one of those happens.
5. The fix for a third-party callback is a signal write or `markForCheck()`, not putting zone.js back.
6. Delete every `NgZone.runOutsideAngular(...)` and `ngZone.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**

```bash
npm install --save-dev vitest jsdom
```

Point the `test` target at the new builder in `angular.json`:

```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.

```bash
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`/`tick` depend on `zone.js/testing`. Once zone.js is gone they throw "zone-testing.js is needed". Replace with `vi.useFakeTimers()` + `vi.advanceTimersByTime(n)`, or with `await fixture.whenStable()` where the test was really waiting for Angular rather than for a timer.
- `done` callbacks → `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 |

```html
@if (isShown()) {
  <div class="panel" animate.enter="panel-enter" animate.leave="panel-leave">…</div>
}
```

```css
@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 |
