Browse Source

bug-fix

imwald
Silberengel 4 weeks ago
parent
commit
bc23ab0fb9
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 113
      src/components/VersionUpdateBanner/index.tsx
  4. 69
      src/lib/pwa-update.ts
  5. 2
      src/main.tsx
  6. 1
      src/vite-env.d.ts
  7. 5
      vite.config.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.13.1", "version": "23.13.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.13.1", "version": "23.13.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "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", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

113
src/components/VersionUpdateBanner/index.tsx

@ -1,9 +1,14 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import {
getPwaApplyUpdate,
initPwaUpdate,
probePwaWaitingWorker,
subscribePwaNeedRefresh
} from '@/lib/pwa-update'
import { RefreshCw, X } from 'lucide-react' import { RefreshCw, X } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import logger from '@/lib/logger'
function readVersionUpdateDismissed(): boolean { function readVersionUpdateDismissed(): boolean {
if (typeof window === 'undefined') return false if (typeof window === 'undefined') return false
@ -21,97 +26,33 @@ export default function VersionUpdateBanner() {
const [isUpdating, setIsUpdating] = useState(false) const [isUpdating, setIsUpdating] = useState(false)
useEffect(() => { 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)) { if (import.meta.env.DEV || typeof window === 'undefined' || !window.isSecureContext || !('serviceWorker' in navigator)) {
return return
} }
/** initPwaUpdate()
* 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> = []
const runCleanup = () => { const showBanner = () => setUpdateAvailable(true)
for (let i = cleanups.length - 1; i >= 0; i--) { const unsubscribe = subscribePwaNeedRefresh(showBanner)
try {
cleanups[i]?.()
} catch {
// ignore
}
}
cleanups.length = 0
}
const onControllerChange = () => {
if (ignoreNextControllerChange) {
ignoreNextControllerChange = false
return
}
if (navigator.serviceWorker.controller) {
setUpdateAvailable(true)
}
}
;(async () => { void probePwaWaitingWorker().then((waiting) => {
try { if (waiting) showBanner()
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 installingListeners: Array<{ worker: ServiceWorker; fn: () => void }> = []
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 = () => { const checkForUpdate = () => {
if (document.hidden) return if (document.hidden) return
registration.update().catch(() => {}) void navigator.serviceWorker.ready
} .then((registration) => registration.update())
const interval = window.setInterval(checkUpdate, 60_000) .catch(() => {})
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 interval = window.setInterval(checkForUpdate, 60_000)
document.addEventListener('visibilitychange', checkForUpdate)
checkForUpdate()
return () => { return () => {
cancelled = true unsubscribe()
runCleanup() window.clearInterval(interval)
document.removeEventListener('visibilitychange', checkForUpdate)
} }
}, []) }, [])
@ -128,18 +69,12 @@ export default function VersionUpdateBanner() {
window.location.reload() window.location.reload()
} }
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { const apply = getPwaApplyUpdate()
reload() if (apply) {
void apply().catch(reload)
return return
} }
void navigator.serviceWorker
.getRegistration()
.then((registration) => {
registration?.waiting?.postMessage({ type: 'SKIP_WAITING' })
reload() reload()
})
.catch(reload)
} }
const handleDismiss = () => { const handleDismiss = () => {

69
src/lib/pwa-update.ts

@ -0,0 +1,69 @@
import logger from '@/lib/logger'
type NeedRefreshListener = () => void
const needRefreshListeners = new Set<NeedRefreshListener>()
let applyUpdate: (() => Promise<void>) | 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<void>) | 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<boolean> {
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
}
}

2
src/main.tsx

@ -13,8 +13,10 @@ import { ErrorBoundary } from './components/ErrorBoundary.tsx'
import { initI18n } from './i18n' import { initI18n } from './i18n'
import { restoreSessionFeedSnapshotsAfterHardRefresh } from './services/session-feed-snapshot.service' import { restoreSessionFeedSnapshotsAfterHardRefresh } from './services/session-feed-snapshot.service'
import { installStaleBuildChunkRecovery } from './lib/stale-chunk-recovery' import { installStaleBuildChunkRecovery } from './lib/stale-chunk-recovery'
import { initPwaUpdate } from './lib/pwa-update'
installStaleBuildChunkRecovery() installStaleBuildChunkRecovery()
initPwaUpdate()
declare global { declare global {
interface Window { interface Window {

1
src/vite-env.d.ts vendored

@ -1,4 +1,5 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/vanillajs" />
import { TNip07 } from '@/types' import { TNip07 } from '@/types'
declare module '*.md?raw' { declare module '*.md?raw' {

5
vite.config.ts

@ -439,7 +439,8 @@ export default defineConfig(({ mode }) => {
fullReloadOnProvidersAndPages(), fullReloadOnProvidersAndPages(),
quietOptionalDevProxyErrors(devIndexRelayTarget), quietOptionalDevProxyErrors(devIndexRelayTarget),
VitePWA({ 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 <link> only; avoid duplicate manifest link in build // Use public/manifest.webmanifest and index.html <link> only; avoid duplicate manifest link in build
manifest: false, manifest: false,
workbox: { workbox: {
@ -447,8 +448,6 @@ export default defineConfig(({ mode }) => {
globDirectory: 'dist/', globDirectory: 'dist/',
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
cleanupOutdatedCaches: true, cleanupOutdatedCaches: true,
skipWaiting: true,
clientsClaim: true,
navigateFallback: '/index.html', navigateFallback: '/index.html',
navigateFallbackDenylist: [/^\/api\//, /^\/_/, /^\/admin/], navigateFallbackDenylist: [/^\/api\//, /^\/_/, /^\/admin/],
// Exclude source files and development files from precaching // Exclude source files and development files from precaching

Loading…
Cancel
Save