Upgrading Angular Two Majors and Going Zoneless
Aug 20, 2026
Once the test suite from the last post was in place, I had Claude Code push this site's Angular version forward — 20 to 22, two majors — and then go all the way to zoneless change detection. I wanted to write up the second part specifically, because it's the kind of change where "the tests pass" and "it actually works" are two different claims, and the gap between them turned up two real bugs.
The upgrade itself
ng update @angular/cli@21 @angular/core@21, then again for 22, one major
at a time like Angular recommends. The 20→21 hop was a non-event — the two
optional migrations it offered didn't even apply to this codebase.
20→22 was more interesting. Two framework-level defaults changed:
provideHttpClient()now defaults to afetch-based backend instead ofXMLHttpRequest. The migration addedwithXhr()everywhere to keep the old behavior rather than silently switching it out from under me.- Angular's own default change-detection strategy flipped to OnPush-like —
ChangeDetectionStrategy.Defaultis now just a deprecated alias for a newEagervalue, and the framework's own docs say "OnPush is enabled by default." Since almost every component in this app mutates a plain property inside an HTTP.subscribe()callback — exactly the pattern that silently stops re-rendering under OnPush — the migration protected all 61 existing components by stampingchangeDetection: ChangeDetectionStrategy.Eageron every one of them, preserving today's behavior exactly.
That last one is worth sitting with. It's not a one-off migration note — it's the framework telling you, in the clearest way it can without breaking your build, that the ground is shifting toward signals-first reactivity. Which is exactly what going zoneless is.
Going zoneless
zone.js is the thing that makes this.posts = data.posts inside a
.subscribe() callback "just work" — it monkey-patches setTimeout,
XMLHttpRequest, and friends so Angular knows to re-check the DOM after
literally anything async happens. Zoneless removes that patching. Without
it, nothing tells Angular to re-render after an async callback mutates a
plain property — only signal writes, template-bound event handlers, and a
handful of other framework-tracked triggers still work automatically.
Nearly every component in this app loads its data the same way:
this.api.getPosts().subscribe(data => {
this.posts = data.posts;
});
Under zoneless, that line stops doing anything visible. The HTTP request
still fires, the property still gets reassigned in memory — the DOM just
never hears about it. So the actual work was going through every
component, finding every property that's (a) read in a template binding
and (b) mutated from inside a .subscribe() or setTimeout callback, and
converting it to a signal(). Twenty components needed it. A few things
turned out to already be safe without any change:
- Fields set synchronously in
ngOnInit(reading a route param, pulling a cached tag list from session storage) — that still happens during the initial render pass regardless of zone.js. - Fields only ever mutated from a template-bound handler, like
(click)="isOpen = !isOpen"— Angular's own event bindings are one of the tracked triggers, zoneless or not.
Two spots needed something a little sharper than a straight signal swap.
AdminPosts.togglePublish() finds a post in the array and flips
post.publish in place before firing the save request — mutating an
object that's already inside a signal doesn't notify anything, only
.set()/.update() do. So after the mutation I added
this.posts.update(posts => [...posts]) — a fresh array reference,
forcing the toggle icon to flip immediately instead of waiting on nothing.
Same story for the nested-item branch of AdminLinks.deleteLink().
Why I didn't trust the green test suite
Here's the part that actually mattered. Every one of the ~130 Karma specs
in this app calls fixture.detectChanges() explicitly right after
triggering a state change — that's just how Angular component testing
works. But that call forces a render pass regardless of what triggered it.
A test can assert the DOM updated correctly and be completely right, while
the same code silently does nothing in a real browser where nothing calls
detectChanges() for it. The test suite genuinely cannot tell the
difference between "this works under zoneless" and "this used to work
under zone.js and I never noticed."
So before calling it done, I had Claude launch the actual dev server, log
in as a real user, and click through the app with Playwright — home page,
admin lists, the publish toggle, the mobile nav menu's outside-click
handler (a raw document.addEventListener, not a template binding — the
one path most likely to break), the search-input autocomplete dropdown.
That's what caught the two real bugs:
AdminLinkEditwas missing from my original sweep entirely. Itscompletionsfield (bound into the category autocomplete) and itserrorfield were still plain properties set inside.subscribe()callbacks. In the browser this threw a liveNG0100: ExpressionChangedAfterItHasBeenCheckedError— Angular's dev-mode assertion that a value changed after the view was already checked. Easy fix once it surfaced: same signal conversion as everywhere else.- A genuinely pre-existing bug, unrelated to any of this. Navigating
to
/admin/link/edit/0— which is what the real "Add Link" button in the admin UI actually points to — crashed with a null-reference error.AdminTagEditandAdminPostEditboth treat a route id of'0'as "this is a new record, don't fetch anything."AdminLinkEditnever got that check, so it dutifully asked the API formenu/link/0, got nothing back, and blew up trying to read.idoffnull. That bug would have crashed identically before any of this — it just never got clicked on by a human, or by a test.
Both got fixed, both got a test that pins the fix, and only after a console-error-free click-through did I let it ship.
Where this landed
- Angular 20 → 22, zero code changes needed for the first hop, two framework-default migrations handled automatically for the second
- Zoneless change detection,
zone.jsgone from the production bundle entirely (moved to dev-only, still needed forfakeAsyncin tests) - 133 tests, still green, plus two regression tests for bugs a green test suite alone would never have caught
The lesson I actually want to remember from this one: a passing test suite proves your code does what your tests describe. It doesn't prove your tests describe what a user does. For a change that alters how rendering gets triggered rather than what gets rendered, that gap is exactly where the real bugs hide — and the only way to close it is to actually run the thing.
tags:angular , claude , playwright , testing , zoneless
