fix(dev): self-heal stale service workers + standardize PWA meta

Two related dev-quality fixes that compose to remove a footgun.

1. Stale service worker self-cleanup (src/lib/dev-sw-cleanup.ts)

Even with VitePWA's devOptions.enabled now false (commit 613a925),
service workers registered during earlier dev sessions linger in
the browser and intercept navigations, often serving cached bundles
from the broken-config period. Manifested as: castle/chat/wallet
not redirecting to /login despite the new auth guard, forum/market
showing "Failed to Start: Cannot read properties of undefined" for
modules that aren't even in their standalone config, hub redirecting
to /market on refresh.

The new helper runs at app boot in dev only:
  - enumerates navigator.serviceWorker.getRegistrations()
  - unregisters every one of them
  - clears caches.keys()
  - reloads once (gated by sessionStorage to avoid loops)

In production builds it's a no-op — the legitimate SW registered
by virtual:pwa-register survives.

Wired into all 8 main.ts entry points (hub + 7 standalones).

2. Apple-mobile-web-app-capable deprecation (.html)

Browsers now warn that <meta name="apple-mobile-web-app-capable">
should be paired with the standardized <meta name="mobile-web-app-capable">.
Adding the standardized tag alongside (kept the apple variant for
older iOS Safari) on all 8 HTML entry points.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-02 13:45:04 +02:00
commit 3ec66151a7
17 changed files with 75 additions and 0 deletions

View file

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/favicon.ico" />

View file

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/favicon.ico" />

View file

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/favicon.ico" />

View file

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/favicon.ico" />

View file

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<!-- <meta name="theme-color" content="#ffffff"> -->

View file

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/favicon.ico" />

View file

@ -1,7 +1,10 @@
import { startApp } from './app'
import { registerSW } from 'virtual:pwa-register'
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
import 'vue-sonner/style.css'
cleanupStaleDevServiceWorkers()
// PWA service worker with periodic updates
const intervalMS = 60 * 60 * 1000 // 1 hour
registerSW({

View file

@ -1,7 +1,10 @@
import { startApp } from './app'
import { registerSW } from 'virtual:pwa-register'
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
import 'vue-sonner/style.css'
cleanupStaleDevServiceWorkers()
// PWA service worker with periodic updates
const intervalMS = 60 * 60 * 1000 // 1 hour
registerSW({

View file

@ -1,7 +1,10 @@
import { startApp } from './app'
import { registerSW } from 'virtual:pwa-register'
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
import 'vue-sonner/style.css'
cleanupStaleDevServiceWorkers()
const intervalMS = 60 * 60 * 1000
registerSW({
onRegistered(r) {

View file

@ -1,7 +1,10 @@
import { startApp } from './app'
import { registerSW } from 'virtual:pwa-register'
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
import 'vue-sonner/style.css'
cleanupStaleDevServiceWorkers()
const intervalMS = 60 * 60 * 1000
registerSW({
onRegistered(r) {

42
src/lib/dev-sw-cleanup.ts Normal file
View file

@ -0,0 +1,42 @@
/**
* Unregister any service worker that was registered on this origin during
* a previous dev session (when VitePWA's devOptions.enabled was true).
*
* Once devOptions.enabled was turned off, Vite stopped registering SWs in
* dev but the browser keeps the previously-registered SWs alive across
* server restarts. They then intercept navigation and serve cached, often
* stale, bundles. This call clears them out at app boot.
*
* Production builds skip this entirely so the legitimate SW from
* `registerSW()` survives.
*/
export async function cleanupStaleDevServiceWorkers(): Promise<void> {
if (!import.meta.env.DEV) return
if (!('serviceWorker' in navigator)) return
try {
const regs = await navigator.serviceWorker.getRegistrations()
if (regs.length === 0) return
console.warn(
`[dev-sw-cleanup] Unregistering ${regs.length} stale service worker(s) from a previous dev session.`
)
await Promise.all(regs.map(r => r.unregister()))
// Also clear any cache the dev SW left behind.
if ('caches' in window) {
const keys = await caches.keys()
await Promise.all(keys.map(k => caches.delete(k)))
}
// Reload once so the next request hits the network instead of the
// about-to-be-removed SW. Guard with a sessionStorage flag so we don't
// loop on browsers that take an extra tick to release the controller.
if (!sessionStorage.getItem('dev-sw-cleanup-reloaded')) {
sessionStorage.setItem('dev-sw-cleanup-reloaded', '1')
window.location.reload()
}
} catch (err) {
console.warn('[dev-sw-cleanup] failed to unregister:', err)
}
}

View file

@ -1,8 +1,12 @@
// New modular application entry point
import { startApp } from './app'
import { registerSW } from 'virtual:pwa-register'
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
import 'vue-sonner/style.css'
// Clean up any leftover dev-mode service workers from a previous session
cleanupStaleDevServiceWorkers()
// Simple periodic service worker updates
const intervalMS = 60 * 60 * 1000 // 1 hour
registerSW({

View file

@ -1,7 +1,10 @@
import { startApp } from './app'
import { registerSW } from 'virtual:pwa-register'
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
import 'vue-sonner/style.css'
cleanupStaleDevServiceWorkers()
const intervalMS = 60 * 60 * 1000
registerSW({
onRegistered(r) {

View file

@ -1,7 +1,10 @@
import { startApp } from './app'
import { registerSW } from 'virtual:pwa-register'
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
import 'vue-sonner/style.css'
cleanupStaleDevServiceWorkers()
const intervalMS = 60 * 60 * 1000
registerSW({
onRegistered(r) {

View file

@ -1,7 +1,10 @@
import { startApp } from './app'
import { registerSW } from 'virtual:pwa-register'
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
import 'vue-sonner/style.css'
cleanupStaleDevServiceWorkers()
const intervalMS = 60 * 60 * 1000
registerSW({
onRegistered(r) {

View file

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/favicon.ico" />

View file

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/favicon.ico" />