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 a fetch-based backend instead of XMLHttpRequest. The migration added withXhr() 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.Default is now just a deprecated alias for a new Eager value, 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 stamping changeDetection: ChangeDetectionStrategy.Eager on 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:

  1. AdminLinkEdit was missing from my original sweep entirely. Its completions field (bound into the category autocomplete) and its error field were still plain properties set inside .subscribe() callbacks. In the browser this threw a live NG0100: 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.
  2. 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. AdminTagEdit and AdminPostEdit both treat a route id of '0' as "this is a new record, don't fetch anything." AdminLinkEdit never got that check, so it dutifully asked the API for menu/link/0, got nothing back, and blew up trying to read .id off null. 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.js gone from the production bundle entirely (moved to dev-only, still needed for fakeAsync in 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.