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
Single Apache Virtualhost for Serving Angular Front End with a Dynamic API, Revisited
Aug 19, 2026
A while back I wrote about serving an Angular front end and a dynamic API from a single Apache virtualhost, using one rewrite rule to send any request that didn't resolve to a real file over to the API's front controller. Revisiting that config recently across a few production sites, I noticed a bug hiding in plain sight in that original post: the prose said unresolved requests should fall through to index.php (the API), but the rewrite rule I actually posted sent them to index.html (the Angular shell) instead. That wasn't a typo — it was the config quietly confessing a real limitation. A single catch-all rule can only pick one target. It can send everything to the SPA, or everything to the API, but not both, based on what the request actually is.
That's fine as long as the API and the SPA never both need to handle requests that don't correspond to real files on disk. In practice they both do: Angular's client-side router needs arbitrary paths like /gallery/foo/bar to resolve to index.html, and a REST API needs paths like /api/posts/42 to resolve to index.php. Neither of those is a real file. The old rule couldn't tell them apart.
The fix is to give the rewrite rules a second decision point: before falling back to the API's front controller, check whether the request path actually belongs to the API.
<Directory /var/www/my_project/public>
# 1. real files/symlinks/dirs served as-is
RewriteCond %{REQUEST_FILENAME} -s [OR]
RewriteCond %{REQUEST_FILENAME} -l [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^.*$ - [NC,L]
# 2. /api/* -> the API's front controller
# (only these two rules are gated)
RewriteCond %{REQUEST_URI} ^/api/
RewriteCond %{REQUEST_URI}::$1 ^(/.+)(.+)::\2$
RewriteRule ^(.*) - [E=BASE:%1]
RewriteCond %{REQUEST_URI} ^/api/
RewriteRule ^(.*)$ %{ENV:BASE}index.php [NC,L]
# 3. everything else -> Angular SPA shell
RewriteRule ^ index.html [NC,L]
</Directory>
Rule 1 is unchanged from before: if the request maps to a real file, symlink, or directory, serve it as-is and stop.
Rule 2 is new. It's gated behind a RewriteCond %{REQUEST_URI} ^/api/ check on both of its rewrite rules, so it only fires for requests under /api/. The BASE-variable trick (the %{REQUEST_URI}::$1 condition) is still there for the same reason as before — it keeps things working if you're using Apache aliases for mass virtual hosting — but now it's scoped to just the API prefix instead of silently applying to every request on the site regardless of whether it was meant for the API.
Rule 3 is the fallback, and it's simpler than before: anything that isn't a real file and isn't under /api/ gets index.html, full stop. No BASE variable needed here, because Angular's router doesn't care about a mod_alias base path the way a PHP front controller does — it just needs the shell document.
One nice side effect of scoping rule 2 to a prefix: you get to choose that prefix. Anything you don't want treated as an API route just doesn't start with /api/, and it falls straight through to the SPA shell instead. The deployment story from the original post — rsync excluding index.php, favicon.ico, and robots.txt from the sync of the Angular build — doesn't change at all. It's still the same three files reserved for the API; they're just reachable in a way that's actually correct now.
tags:angular , apache , back end , front end , laminas , laravel
Single Apache Virtualhost for Serving Angular Front End with a Dynamic API
Oct 11, 2025
Because Angular, on production, is basically a set of static files, and a dynamic API relies on Apache serving only one static file (e.g. index.php), and the two sets don't collide, we can serve them from the same virtualhost with a small bit of care. To do so, we use standard apache rewrite rules to redirect all requests that don't resolve to a real static file to the API's single static file (again, e.g. index.php). Otherwise we just serve the static file (angular ts or json, image, css, etc).
Where we need to be careful is in the deployment. I tend to lock in the API first and it stays, more or less, fixed during development of the front end. This is what I initially clone into my production source tree. Then I have a CI/CD pipeline that builds my front end, tests it, and if all is well, I use rsync to sync it to my document root in the source tree. The rsync command deletes old files with the exclusion of the 3 static files that my API provided:
rsync -e ssh -avz \
--delete \
--exclude index.php \
--exclude favicon.ico \
--exclude robots.txt \
dist/my_project/browser/ my-server.com:/var/www/my_project/public/
My virtualhost configuration uses standard rewrite rules as follows:
<directory>
# The following rule tells Apache that if the requested filename
# exists, simply serve it.
RewriteCond %{REQUEST_FILENAME} -s [OR]
RewriteCond %{REQUEST_FILENAME} -l [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^.*$ - [NC,L]
# The following rewrites all other queries to index.php. The
# condition ensures that if you are using Apache aliases to do
# mass virtual hosting, the base path will be prepended to
# allow proper resolution of the index.php file; it will work
# in non-aliased environments as well, providing a safe, one-size
# fits all solution.
RewriteCond %{REQUEST_URI}::$1 ^(/.+)(.+)::\2$
RewriteRule ^(.*) - [E=BASE:%1]
RewriteRule ^(.*)$ %{ENV:BASE}index.html [NC,L]
</directory>
tags:angular , apache , back end , front end , laminas , laravel
