Org Skills

The migrations with no schematic

Supporting material for angular-migration. Agents load it on demand; it ships inside the skill folder.

RawSource

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

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/fdescribeit.only/describe.only, xit/xdescribeit.skip/describe.skip, spyOnvi.spyOn, jasmine.createSpyvi.fn, jasmine.objectContainingexpect.objectContaining, jasmine.anyexpect.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
@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