fix(pwa): skip the controllerchange reload on a first-ever install (#585)
User description
Fixes #585 (production-behavior direction only — see Non-goals).
Problem
public/sw.js calls self.clients.claim() on activate, and register-sw.ts's controllerchange handler unconditionally calls flushLatestStateThenReload() whenever the controller changes while the page is visible. clients.claim() claims already-open clients immediately, not just future navigations — so a completely fresh browser context's very first page load fires controllerchange on that same first-ever page, not only on a version update for a returning visitor. Every first-time visitor, and every fresh E2E browser context, underwent one automatic, unprompted reload shortly after the initial page load.
Root cause
The reload's documented purpose is avoiding a missing-chunk failure, because activate already pruned the old version's cache by the time controllerchange fires — a risk specific to genuine updates. A first-ever install has no prior version to be stale relative to.
Fix (converged through many rounds of review — each closing a narrower race or correctness gap than the last)
The final architecture, after the classification logic went through several rejected intermediate designs (each broken by a real, reproduced counter-example, each superseded fix verified with a negative control — revert → confirm the new test fails → restore → confirm it passes):
- Absolute-URL identity.
ServiceWorker.scriptURLis always a fully resolved absolute URL per spec;swUrlis resolved to an absolute URL once (new URL(...).href) before every comparison, so this app's own worker is identified correctly instead of never matching (an earlier relative-path comparison would have silently misclassified every returning visitor). - Foreign-worker filtering. On a shared origin (e.g. GitHub Pages), a broader-scoped worker's own
controllerchangeis ignored outright — verified byscriptURLbefore any queuing or classification — so it can never consume the one-shot exemption or write this app's own install record. - Persistent installation history. A
localStoragerecord keyed by this app's own absolute script URL is written from any positive evidence (an own controller already at page load, an own'activated'registration, or a first genuine claim), so future page loads classify instantly and correctly without depending on live Service Worker API timing at all. - Event queuing during classification. A
controllerchangearriving before classification is final — whether duringgetRegistration(), during its failure-recovery window, or duringregister()itself — is queued, not guessed at, and replayed once classification is final. - getRegistration()-failure recovery. If
getRegistration()rejects, classification is deferred rather than finalized on zero evidence, and recovered fromregister()'s own idempotent result once it resolves — so a force-refreshed returning visitor hitting a transient lookup failure is still classified correctly instead of misclassified as a first install. Ifregister()also rejects, classification finalizes to a safe first-install default so no controllerchange is left queued forever. - One-shot exemption. The first-install classification is consumed after its first use, so a long-lived tab that outlives its own first-install claim still reloads correctly when a later, genuine update takes over.
A full state-transition audit (persistent marker × controller-at-setup × getRegistration() outcome × prior-registration state × register() outcome × controllerchange timing) was performed before the architecture above was accepted as final — see the last two commits' messages for the two gaps it found and closed, and what it confirmed was already correct.
Scope
register-sw.ts's controllerchange handler and its pre-register() detection logic, only.
Non-goals
Per #585's own "Scope for a fix" section, this closes only direction 1 (the production reload decision). Direction 2 (hardening tests/e2e/helpers.ts's startup helpers against an unprompted navigation) is intentionally left to #532, which remains open and already names controllerchange-driven reload as one of several candidate mechanisms in its own still-unresolved startup/harness nondeterminism investigation — this PR does not attempt to close or reopen #532.
A pre-existing, unrelated finding (flushLatestStateThenReload's timeout branch racing an in-flight flush) was verified byte-identical against base main and is out of scope here — dispositioned in review as a variant of the residual class already tracked in #518, not fixed in this PR.
A narrow, compound-timing residual — two tabs racing the very first-ever activation of this origin's service worker so closely that one tab's live (or, in a genuinely first-ever wave, not-yet-persisted) evidence reads as "already installed" — cannot be closed with a local, snapshot-based fix; it requires real cross-tab coordination (e.g. a BroadcastChannel-based install-wave announcement), which is new architectural surface, not a targeted bugfix. This is tracked in #614, not attempted here.
Regression coverage
tests/unit/registerSwUpdateFlush.test.ts — grew from 10 tests to 29 across the review cycle, covering: first-ever install (no registration), one-shot consumption, force-refresh with an existing 'activated' registration, concurrent tabs observing each other's in-progress (installing-only or 'activating'-but-not-yet-'activated') registration, absolute-vs-relative scriptURL scoping, foreign-worker exclusion, controllerchange events queued mid-getRegistration() and mid-register(), persistent-record backfill from positive live evidence (including the failure-recovery path), classification recovering from register()'s result when getRegistration() rejects, and the compound double-failure safe default. Every fix above was verified with its own negative control before being accepted.
Validation
pnpm exec vitest run tests/unit/registerSwUpdateFlush.test.ts— 29/29 pass on the final head.pnpm run lint -- register-sw.ts tests/unit/registerSwUpdateFlush.test.ts(exact CI command) — clean.node scripts/check-doc-metrics.mjs— clean after syncing README's test count (7456+ tests / 598 files).- Full CI is the authoritative gate for typecheck/build/E2E; not rerun locally per this repo's low-end-hardware guidance.
Summary by Sourcery## Summary by Sourcery
Skip the unnecessary first-load reload caused by service worker activation while retaining safe reload behavior for returning visitors after updates.
Bug Fixes:
- Prevent the service worker from triggering an automatic flush-and-reload during its first-ever activation while preserving reloads for genuine updates.
- Correct service worker ownership and installation classification across force refreshes, concurrent installs, shared origins, and asynchronous registration failures.
Enhancements:
- Persist app-specific service worker installation history and queue controller changes until classification is complete.
- Refactor service worker setup into focused helpers without changing update notifications, Tauri cleanup, or periodic sync behavior.
Documentation:
- Synchronize README test-count metrics with the expanded regression suite.
Tests:
- Expand registration and update regression coverage to 29 tests for first installs, update races, worker ownership, persistence, and failure recovery.
Summary by cubic
Fixes #585 by stopping the service worker's controllerchange reload from firing on a first-ever install, so first-time visitors no longer get an automatic reload. Genuine updates still flush state and reload for returning visitors.
Bug Fixes
- Classifies a first install only when no own controller, no own
'activated'registration, and no persistent record existed at load; foreign workers on shared origins never count. - Resolves
swUrlto an absolute URL before comparing againstServiceWorker.scriptURL— the previous relative comparison silently misclassified every returning visitor. - Queues
controllerchangeevents arriving before classification completes and replays them once final, so claims racing the async setup aren't missed. - Recovers classification from
register()'s result whengetRegistration()rejects; if both reject, defaults to first-install so no events stay queued forever. - Persists a
localStoragerecord keyed by this app's own script URL from positive evidence, so future loads classify without live-API timing. - The exemption is one-shot, so a later genuine update on a long-lived tab still reloads.
Refactors
- Extracted classification, Tauri teardown, update-notification setup, and periodic sync into dedicated functions with no behavior change.
- Added regression tests covering installs, concurrent tabs, force-refreshed visitors, foreign workers, queued/rejected lookups, persistent records, and later updates.
Summary by CodeRabbit
-
Bug Fixes
- Prevented unnecessary page reloads and persisted-state flushing during first-time service-worker installation, including installations racing asynchronous setup.
- Preserved visibility-aware page reload behavior for subsequent service-worker updates.
- Improved handling of service-worker updates during navigation and background synchronization.
- Ignored unrelated service workers and improved reliability when detecting updates across refreshes and delayed registrations.
-
Documentation
- Updated README test metrics to reflect 7,452+ tests across project badges, technology details, structure, and CI metrics.
CodeAnt-AI Description
Prevent first-install reloads while preserving service worker update refreshes
What Changed
- First-time visitors no longer get an automatic page reload when the service worker takes control.
- Returning visitors still flush pending changes and reload after genuine service worker updates.
- Install history is tracked for this app, including cases where the page has no current controller or an update is still activating.
- Service worker changes from unrelated apps on shared origins are ignored.
- Added regression coverage for first installs, update races, hidden tabs, failed registration checks, foreign workers, and later updates in long-lived tabs.
Impact
✅ No unexpected reload on first visit
✅ Preserved update reloads for returning visitors
✅ Fewer lost or stale app-state risks during updates
💡 Usage Guide
Checking Your Pull Request
Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.
Talking to CodeAnt AI
Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:
@codeant-ai ask: Your question here
This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.
Example
@codeant-ai ask: Can you suggest a safer alternative to storing this secret?
Preserve Org Learnings with CodeAnt
You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:
@codeant-ai: Your feedback here
This helps CodeAnt AI learn and adapt to your team's coding style and standards.
Example
@codeant-ai: Do not flag unused imports.
Retrigger review
Ask CodeAnt AI to review the PR again, by typing:
@codeant-ai: review
Check Your Repository Health
To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.