drewb.com

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() plus focus and visibilitychange triggers
  • A build_all step that keeps ngsw.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.

tags:angularpwa

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.

tags:angularapache

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 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.

tags:angularclaudeplaywrighttestingzoneless

© 2000-2026 Drew Bertola Site Map

v0.0.19 · built Sep 13, 2026, 11:57:50 PM