From bc23ab0fb9f4f7bb063b48cb55930f2c035b102f Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 20 May 2026 21:53:12 +0200 Subject: [PATCH] bug-fix --- package-lock.json | 4 +- package.json | 2 +- src/components/VersionUpdateBanner/index.tsx | 121 +++++-------------- src/lib/pwa-update.ts | 69 +++++++++++ src/main.tsx | 2 + src/vite-env.d.ts | 1 + vite.config.ts | 5 +- 7 files changed, 105 insertions(+), 99 deletions(-) create mode 100644 src/lib/pwa-update.ts diff --git a/package-lock.json b/package-lock.json index d89f34e5..369d0ad4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.13.1", + "version": "23.13.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.13.1", + "version": "23.13.3", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 0e52df32..4dba82e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.13.2", + "version": "23.13.3", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/components/VersionUpdateBanner/index.tsx b/src/components/VersionUpdateBanner/index.tsx index e342d619..c179b1e8 100644 --- a/src/components/VersionUpdateBanner/index.tsx +++ b/src/components/VersionUpdateBanner/index.tsx @@ -1,9 +1,14 @@ import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' +import { + getPwaApplyUpdate, + initPwaUpdate, + probePwaWaitingWorker, + subscribePwaNeedRefresh +} from '@/lib/pwa-update' import { RefreshCw, X } from 'lucide-react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import logger from '@/lib/logger' function readVersionUpdateDismissed(): boolean { if (typeof window === 'undefined') return false @@ -21,97 +26,33 @@ export default function VersionUpdateBanner() { const [isUpdating, setIsUpdating] = useState(false) useEffect(() => { - // Skip in dev: no SW is registered (vite-plugin-pwa devOptions.enabled: false), and .ready can reject with "operation is insecure" if (import.meta.env.DEV || typeof window === 'undefined' || !window.isSecureContext || !('serviceWorker' in navigator)) { return } - /** - * Workbox is built with skipWaiting + clientsClaim, so `registration.waiting` is almost never - * set — the new worker activates immediately. The reliable signal is `controllerchange`. - * Skip the first such event when we started without a controller (first install for this origin). - */ - let ignoreNextControllerChange = !navigator.serviceWorker.controller - let cancelled = false - const cleanups: Array<() => void> = [] + initPwaUpdate() - const runCleanup = () => { - for (let i = cleanups.length - 1; i >= 0; i--) { - try { - cleanups[i]?.() - } catch { - // ignore - } - } - cleanups.length = 0 - } - - const onControllerChange = () => { - if (ignoreNextControllerChange) { - ignoreNextControllerChange = false - return - } - if (navigator.serviceWorker.controller) { - setUpdateAvailable(true) - } - } - - ;(async () => { - try { - const registration = await navigator.serviceWorker.ready - if (cancelled || !registration) return - - navigator.serviceWorker.addEventListener('controllerchange', onControllerChange) - cleanups.push(() => navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange)) - - if (registration.waiting) { - setUpdateAvailable(true) - } + const showBanner = () => setUpdateAvailable(true) + const unsubscribe = subscribePwaNeedRefresh(showBanner) - const installingListeners: Array<{ worker: ServiceWorker; fn: () => void }> = [] + void probePwaWaitingWorker().then((waiting) => { + if (waiting) showBanner() + }) - const handleUpdateFound = () => { - const newWorker = registration.installing - if (!newWorker) return - - const onState = () => { - if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { - setUpdateAvailable(true) - } - } - // May already be `installed` before we attach (skipWaiting race) - onState() - newWorker.addEventListener('statechange', onState) - installingListeners.push({ worker: newWorker, fn: onState }) - } - - registration.addEventListener('updatefound', handleUpdateFound) - cleanups.push(() => registration.removeEventListener('updatefound', handleUpdateFound)) - cleanups.push(() => { - for (const { worker, fn } of installingListeners) { - worker.removeEventListener('statechange', fn) - } - installingListeners.length = 0 - }) - - const checkUpdate = () => { - if (document.hidden) return - registration.update().catch(() => {}) - } - const interval = window.setInterval(checkUpdate, 60_000) - cleanups.push(() => window.clearInterval(interval)) - document.addEventListener('visibilitychange', checkUpdate) - cleanups.push(() => document.removeEventListener('visibilitychange', checkUpdate)) - - checkUpdate() - } catch (error) { - logger.debug('Service worker update check skipped or failed', { error }) - } - })() + const checkForUpdate = () => { + if (document.hidden) return + void navigator.serviceWorker.ready + .then((registration) => registration.update()) + .catch(() => {}) + } + const interval = window.setInterval(checkForUpdate, 60_000) + document.addEventListener('visibilitychange', checkForUpdate) + checkForUpdate() return () => { - cancelled = true - runCleanup() + unsubscribe() + window.clearInterval(interval) + document.removeEventListener('visibilitychange', checkForUpdate) } }, []) @@ -128,18 +69,12 @@ export default function VersionUpdateBanner() { window.location.reload() } - if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { - reload() + const apply = getPwaApplyUpdate() + if (apply) { + void apply().catch(reload) return } - - void navigator.serviceWorker - .getRegistration() - .then((registration) => { - registration?.waiting?.postMessage({ type: 'SKIP_WAITING' }) - reload() - }) - .catch(reload) + reload() } const handleDismiss = () => { diff --git a/src/lib/pwa-update.ts b/src/lib/pwa-update.ts new file mode 100644 index 00000000..3408421c --- /dev/null +++ b/src/lib/pwa-update.ts @@ -0,0 +1,69 @@ +import logger from '@/lib/logger' + +type NeedRefreshListener = () => void + +const needRefreshListeners = new Set() +let applyUpdate: (() => Promise) | undefined +let initialized = false + +export function subscribePwaNeedRefresh(listener: NeedRefreshListener): () => void { + needRefreshListeners.add(listener) + return () => { + needRefreshListeners.delete(listener) + } +} + +function notifyPwaNeedRefresh(): void { + for (const listener of needRefreshListeners) { + try { + listener() + } catch (error) { + logger.debug('PWA need-refresh listener error', { error }) + } + } +} + +export function getPwaApplyUpdate(): (() => Promise) | undefined { + return applyUpdate +} + +/** + * Register the service worker and surface {@link notifyPwaNeedRefresh} via vite-plugin-pwa prompt mode. + * Importing `virtual:pwa-register` prevents the auto-injected `registerSW.js` script tag. + */ +export function initPwaUpdate(): void { + if (initialized || import.meta.env.DEV) return + if (typeof window === 'undefined' || !window.isSecureContext || !('serviceWorker' in navigator)) { + return + } + initialized = true + + void import('virtual:pwa-register') + .then(({ registerSW }) => { + applyUpdate = registerSW({ + immediate: true, + onNeedRefresh() { + notifyPwaNeedRefresh() + }, + onRegisterError(error: unknown) { + logger.debug('Service worker registration failed', { error }) + } + }) + }) + .catch((error) => { + logger.debug('PWA registration module unavailable', { error }) + }) +} + +/** True when a new build is installed but waiting for user confirmation (prompt mode). */ +export async function probePwaWaitingWorker(): Promise { + if (import.meta.env.DEV || typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { + return false + } + try { + const registration = await navigator.serviceWorker.ready + return Boolean(registration.waiting) + } catch { + return false + } +} diff --git a/src/main.tsx b/src/main.tsx index 99879214..89345278 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -13,8 +13,10 @@ import { ErrorBoundary } from './components/ErrorBoundary.tsx' import { initI18n } from './i18n' import { restoreSessionFeedSnapshotsAfterHardRefresh } from './services/session-feed-snapshot.service' import { installStaleBuildChunkRecovery } from './lib/stale-chunk-recovery' +import { initPwaUpdate } from './lib/pwa-update' installStaleBuildChunkRecovery() +initPwaUpdate() declare global { interface Window { diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 3df459de..4856f59c 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,4 +1,5 @@ /// +/// import { TNip07 } from '@/types' declare module '*.md?raw' { diff --git a/vite.config.ts b/vite.config.ts index 40eb18f8..36c1465e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -439,7 +439,8 @@ export default defineConfig(({ mode }) => { fullReloadOnProvidersAndPages(), quietOptionalDevProxyErrors(devIndexRelayTarget), VitePWA({ - registerType: 'autoUpdate', + // Prompt mode + virtual:pwa-register (main bundle) — do not auto-activate; VersionUpdateBanner calls updateSW(). + registerType: 'prompt', // Use public/manifest.webmanifest and index.html only; avoid duplicate manifest link in build manifest: false, workbox: { @@ -447,8 +448,6 @@ export default defineConfig(({ mode }) => { globDirectory: 'dist/', maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, cleanupOutdatedCaches: true, - skipWaiting: true, - clientsClaim: true, navigateFallback: '/index.html', navigateFallbackDenylist: [/^\/api\//, /^\/_/, /^\/admin/], // Exclude source files and development files from precaching