From 95cbf065f16beb20f332deba5a65d9b312286d8d Mon Sep 17 00:00:00 2001 From: DeePrincipal-dev-lang Date: Tue, 28 Apr 2026 12:21:42 +0000 Subject: [PATCH] feat: add PWA update notifications and service worker update flow - Add public/sw.js with skipWaiting and clientsClaim - Add src/utils/registerSW.ts with update detection logic - Add src/components/UpdateBanner.tsx update prompt UI - Refactor AppUpdateManager to use UpdateBanner - Update usePWA to delegate to registerSW/applyUpdate utilities Closes #issue --- public/sw.js | 39 +++++++++++++++++++ src/components/UpdateBanner.tsx | 50 +++++++++++++++++++++++++ src/components/pwa/AppUpdateManager.tsx | 38 +------------------ src/hooks/usePWA.tsx | 47 ++++------------------- src/utils/registerSW.ts | 48 ++++++++++++++++++++++++ 5 files changed, 147 insertions(+), 75 deletions(-) create mode 100644 public/sw.js create mode 100644 src/components/UpdateBanner.tsx create mode 100644 src/utils/registerSW.ts diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 00000000..98108c6b --- /dev/null +++ b/public/sw.js @@ -0,0 +1,39 @@ +const CACHE_NAME = 'teachlink-v1'; +const OFFLINE_URL = '/offline.html'; + +// Claim all clients immediately on activation +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.add(OFFLINE_URL)), + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + Promise.all([ + // Remove old caches + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))), + ), + // Take control of all open clients immediately + self.clients.claim(), + ]), + ); +}); + +self.addEventListener('fetch', (event) => { + if (event.request.mode === 'navigate') { + event.respondWith( + fetch(event.request).catch(() => + caches.match(OFFLINE_URL).then((r) => r ?? new Response('Offline', { status: 503 })), + ), + ); + } +}); + +// Allow the waiting SW to skip the waiting phase and activate immediately +self.addEventListener('message', (event) => { + if (event.data?.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); diff --git a/src/components/UpdateBanner.tsx b/src/components/UpdateBanner.tsx new file mode 100644 index 00000000..0101526b --- /dev/null +++ b/src/components/UpdateBanner.tsx @@ -0,0 +1,50 @@ +'use client'; + +import React from 'react'; +import { RefreshCw, X } from 'lucide-react'; + +interface UpdateBannerProps { + onUpdate: () => void; + onDismiss: () => void; +} + +export const UpdateBanner: React.FC = ({ onUpdate, onDismiss }) => ( +
+
+
+
+
+

Update Available

+

+ A new version of TeachLink is ready. Refresh to get the latest features. +

+
+ + +
+
+ +
+
+); diff --git a/src/components/pwa/AppUpdateManager.tsx b/src/components/pwa/AppUpdateManager.tsx index 2b7dff87..5b158952 100644 --- a/src/components/pwa/AppUpdateManager.tsx +++ b/src/components/pwa/AppUpdateManager.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { usePWA } from '@/hooks/usePWA'; -import { RefreshCw, X } from 'lucide-react'; +import { UpdateBanner } from '@/components/UpdateBanner'; export const AppUpdateManager: React.FC = () => { const { updateAvailable, updateApp } = usePWA(); @@ -10,39 +10,5 @@ export const AppUpdateManager: React.FC = () => { if (!updateAvailable || !show) return null; - return ( -
-
-
- -
-
-

Update Available

-

- A new version of TeachLink is available with new features and improvements. -

-
- - -
-
- -
-
- ); + return setShow(false)} />; }; diff --git a/src/hooks/usePWA.tsx b/src/hooks/usePWA.tsx index faa577a0..6ca678eb 100644 --- a/src/hooks/usePWA.tsx +++ b/src/hooks/usePWA.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback } from 'react'; +import { registerSW, applyUpdate } from '@/utils/registerSW'; interface BeforeInstallPromptEvent extends Event { readonly platforms: string[]; @@ -17,7 +18,6 @@ export const usePWA = () => { const [updateAvailable, setUpdateAvailable] = useState(false); const [registration, setRegistration] = useState(null); const [isOffline, setIsOffline] = useState(false); - const updateFoundHandlerRef = useRef<(() => void) | null>(null); useEffect(() => { // Check if app is already installed @@ -57,37 +57,13 @@ export const usePWA = () => { }, []); const registerServiceWorker = useCallback(async () => { - if ('serviceWorker' in navigator && process.env.NODE_ENV === 'production') { - try { - const reg = await navigator.serviceWorker.register('/serviceWorker.js'); - setRegistration(reg); - - const handleUpdateFound = () => { - const newWorker = reg.installing; - if (newWorker) { - newWorker.addEventListener('statechange', () => { - if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { - setUpdateAvailable(true); - } - }); - } - }; - updateFoundHandlerRef.current = handleUpdateFound; - reg.addEventListener('updatefound', handleUpdateFound); - } catch (error) { - console.error('Service worker registration failed:', error); - } - } + const reg = await registerSW((r) => { + setRegistration(r); + setUpdateAvailable(true); + }); + if (reg) setRegistration(reg); }, []); - useEffect(() => { - return () => { - if (registration && updateFoundHandlerRef.current) { - registration.removeEventListener('updatefound', updateFoundHandlerRef.current); - } - }; - }, [registration]); - const installApp = async () => { if (!installPrompt) return; await installPrompt.prompt(); @@ -98,14 +74,7 @@ export const usePWA = () => { }; const updateApp = () => { - if (registration?.waiting) { - registration.waiting.postMessage({ type: 'SKIP_WAITING' }); - registration.waiting.addEventListener('statechange', (e) => { - if ((e.target as ServiceWorker).state === 'activated') { - window.location.reload(); - } - }); - } + if (registration) applyUpdate(registration); }; return { diff --git a/src/utils/registerSW.ts b/src/utils/registerSW.ts new file mode 100644 index 00000000..5d408467 --- /dev/null +++ b/src/utils/registerSW.ts @@ -0,0 +1,48 @@ +export type UpdateCallback = (registration: ServiceWorkerRegistration) => void; + +/** + * Registers /sw.js and calls `onUpdate` whenever a new service worker is + * waiting to activate (i.e. an update is available). + */ +export async function registerSW(onUpdate?: UpdateCallback): Promise { + if (typeof window === 'undefined' || !('serviceWorker' in navigator)) return null; + + try { + const registration = await navigator.serviceWorker.register('/sw.js'); + + const checkForWaiting = (reg: ServiceWorkerRegistration) => { + if (reg.waiting) { + onUpdate?.(reg); + return; + } + + reg.addEventListener('updatefound', () => { + const installing = reg.installing; + if (!installing) return; + + installing.addEventListener('statechange', () => { + if (installing.state === 'installed' && navigator.serviceWorker.controller) { + onUpdate?.(reg); + } + }); + }); + }; + + checkForWaiting(registration); + + // Also check on subsequent page loads when a SW may already be waiting + navigator.serviceWorker.addEventListener('controllerchange', () => { + window.location.reload(); + }); + + return registration; + } catch (err) { + console.error('[SW] Registration failed:', err); + return null; + } +} + +/** Tells the waiting service worker to skip waiting and take control. */ +export function applyUpdate(registration: ServiceWorkerRegistration): void { + registration.waiting?.postMessage({ type: 'SKIP_WAITING' }); +}