Telling an Installed PWA That a New Version Shipped
Sep 10, 2026
This site is an installable PWA. That's a nice feature and a genuinely annoying one: once someone adds it to their home screen, the Angular service worker caches the whole app shell and serves it instantly on every launch — including, by default, forever, or at least until every tab and window for the origin has been closed and reopened. On a phone, where the OS silently kills backgrounded apps and "reopening" is a cold start against the cache, that means a user can run a build from three deploys ago indefinitely and have no way of knowing.
So I wanted two things: a version number the user can actually see, and a small nudge when a newer one is ready to activate. Neither is much code. Getting the nudge to actually fire on a real installed PWA took four separate fixes, and every one of them was invisible until the app was running on a phone with a debugger attached.
The visible version
A tiny script, scripts/generate-build-info.mjs, reads the version
field out of package.json and writes it — with a build timestamp — to a
gitignored src/app/build-info.ts:
export const BUILD_INFO = {
version: '0.0.18',
builtAt: '2026-09-09T16:56:15.102Z',
};
The root component imports that and renders it in the page footer:
v0.0.18 · built Sep 9, 2026, 11:56:15 PM. The script runs from prestart,
prebuild, prewatch and pretest npm hooks so it's always current in
dev and CI. The deploy script (build_all) calls ng build directly,
which bypasses the npm hook, so it runs the generator itself as its first
step.
This isn't glamorous, but it's the thing that makes every future bug report tractable: "what does the footer say?" immediately tells me whether someone is even on the build I think they're on. It's also the target the update banner gets tested against — bump the number, deploy, watch for the banner.
The banner
Ported from another project of mine. AppUpdateService listens to
Angular's SwUpdate.versionUpdates stream, filters for VERSION_READY
(the event that means "a new version is fully downloaded and sitting in
the cache, ready to activate"), and flips a signal:
this.swUpdate.versionUpdates
.pipe(filter((e): e is VersionReadyEvent => e.type === 'VERSION_READY'))
.subscribe(() => this.updateAvailable.set(true));
A presentational UpdateBanner component renders a fixed bar at the
bottom of the viewport when that signal is true, with a Refresh button
that calls swUpdate.activateUpdate() and then reloads the page. That's
the entire user-facing feature.
And it didn't work. The banner never showed up in installed-PWA mode on mobile — which is the only mode where it matters.
Four bugs, none of them in the banner
1. The service worker never registered in time
Angular's default registration strategy is registerWhenStable:30000:
wait until the app has no pending macro/microtasks, then register, or
give up waiting after 30 seconds. But this app's root ngOnInit always
has an HTTP request in flight (whoami(), sometimes getTags() too). On
a fast connection that resolves quickly and stability is reached. On a
slow one — or in a mobile PWA session that the OS kills after a few
seconds — the app never reaches "stable," so the service worker
registration itself never runs. No registration, no update checks, ever.
Fix: registrationStrategy: 'registerImmediately'. Against a real
production build served statically, the SW registered ~95ms after
navigation instead of never.
2. The update check window was never entered
AppUpdateService originally only called checkForUpdate() on a 6-hour
interval, or when the page went from hidden to visible. Both assumptions
are true of a desktop browser tab, which often lives in the background
for hours. Neither is true of an installed PWA on a phone: it gets fully
killed when backgrounded, so reopening it is a cold start with the tab
already visible when the listener attaches (no hidden→visible transition
to catch), and a session basically never stays open long enough to hit a
6-hour timer.
Fix: call checkForUpdate() once immediately on boot. And add focus as
a second trigger alongside visibilitychange, because Android
WebAPK/Chrome doesn't reliably fire a visibilitychange transition when
you resume the app from the task switcher, but window focus has
proven more consistent there.
3. The service worker was silently rejecting every update
This is the one that took a real debugger. With registration and the
boot-time check both fixed, checkForUpdate() was running, resolving
cleanly — and no VERSION_READY ever arrived.
build_all does a post-processing pass after ng build: a sed rewrite
that appends ?v=<content-hash> to the icon URLs in index.html and
manifest.webmanifest, so that Android's WebAPK installer — which only
refreshes the home-screen icon when the manifest's icon URL changes,
not when the bytes behind a stable URL change — always sees a new URL
after an icon change.
The problem: Angular's CLI computes ngsw.json's hash table (the
service worker's integrity manifest) during ng build, from the
original bytes of those two files. The sed pass then changes those
bytes. So from that point on, index.html and manifest.webmanifest as
served never matched the SHA-1 the service worker expected. The SW would
download the new version, run its integrity check, find the mismatch, and
abort with VERSION_INSTALLATION_FAILED: Hash mismatch — permanently,
every time. And checkForUpdate() still resolves without throwing when
that happens, so from the app's point of view everything looked fine.
I only found it by attaching chrome://inspect to the phone, dropping
temporary console.log calls into every step of AppUpdateService, and
watching the actual versionUpdates events come through as
VERSION_INSTALLATION_FAILED instead of VERSION_READY.
Fix: build_all now recomputes the SHA-1 of index.html and
manifest.webmanifest after the sed pass and patches the new hashes
straight into ngsw.json's hashTable with a small node -e snippet.
If the icon-stamping step or ngsw-config.json ever changes, this needs
re-verifying — the failure mode produces no error anywhere until a real
device tries to install the build.
4. Caching headers on the manifest files
For the update check to even get a chance, the service worker has to be
able to fetch a fresh ngsw.json and index.html — if the CDN or
browser hands it a cached copy, it compares the new build against itself
and sees no change. The Apache vhost sends Cache-Control: no-cache for
index.html, index.csr.html, manifest.webmanifest, ngsw.json and
the worker scripts, and Cache-Control: public, max-age=31536000, immutable for the content-hashed main-XXXXXXXX.js /
styles-XXXXXXXX.css bundles (whose names change when their content
does, so they're safe to pin forever).
The workflow that falls out of this
Every deploy bumps the patch version in package.json
(npm version 0.0.x --no-git-tag-version, committed) before running
build_all. That guarantees there's always a new version string for the
footer and always something for the service worker to detect. The commit
history is full of Bump version to 0.0.N to test <thing> — that's the
feedback loop: each real deploy re-exercises the whole update path, and
the footer plus the banner tell me within one app launch whether it still
works.
Testing
AppUpdateService has unit tests that stub SwUpdate and override the
reload() call. They're worth having. But they could not have caught a
single one of the four bugs above — registration timing, the missing
boot-time check, the hash mismatch, and the cache headers are all things
that only exist once the app is built, deployed, installed on a phone,
and inspected live. This is the same lesson as going zoneless: a green
suite proves the code does what the tests describe, not that the tests
describe what actually happens in the field.
Where this landed
- A build version + timestamp in the footer, regenerated on every
build/start/test from
package.json - A
VERSION_READY-driven refresh banner, ~15 lines of app code registrationStrategy: 'registerImmediately'so short mobile sessions actually register the worker- A boot-time
checkForUpdate()plusfocusandvisibilitychangetriggers - A
build_allstep that keepsngsw.json's hash table honest after the icon-URL rewrite - Cache-Control headers that let the worker see fresh manifests
The user-facing part is trivial. The reason it took days of version-bump commits is that a service worker update can fail at registration, at the check, at download, at the integrity check, or at activation — and it resolves cleanly at every one of those stages. You cannot debug it from "did the banner show?" You have to watch the events.
Apache Rewrite Rules for a Hybrid-Rendered Angular SPA
Aug 24, 2026
I just moved the Angular front end over to hybrid rendering - Angular prerenders the blog post pages and a handful of static routes at build time, so search engines and social-link crawlers get real HTML instead of an empty SPA shell that only fills in after the JS bundle runs. Everything else (search, tag filtering, the admin panel) still renders client-side, same as before.
That broke the fallback rule from the last revision of this config:
RewriteRule ^(.*)$ %{ENV:BASE}index.html [NC,L]
Before, index.html was always the same neutral SPA shell no matter
what route hit it - Angular's router figured out the rest client-side.
Now that the build prerenders content at /, index.html is the
baked-in home page. Hit /admin/posts on a cold load and Apache would
happily serve you the home page's markup instead of a blank shell,
and Angular would have to tear it down and re-render before the admin
page showed up.
The fix is Angular's own convention for this: alongside every
prerendered route's index.html, the build also emits a neutral
index.csr.html specifically for this fallback case. Point the
rewrite at that instead:
RewriteRule ^(.*)$ %{ENV:BASE}index.csr.html [NC,L]
One more spot needed the same file added - the no-cache header rule
that already covered index.html so a stale reference to a deleted
asset hash never survives a deploy:
<FilesMatch "^(index\.html|index\.csr\.html|manifest\.webmanifest|ngsw\.json|ngsw-worker\.js|safety-worker\.js|worker-basic\.min\.js)$">
Header set Cache-Control "no-cache"
</FilesMatch>
Prerendered routes still resolve correctly without touching this rule
at all - rule #1 in the <Directory> block already serves any request
matching a real file or directory as-is, and now /post/16/... really
is one, so it never falls through to the rewrite in the first place.
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
