Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions src/hooks/useRegisterSW/pwaUpdate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
hasRecentPWAUpdateReload,
markPWAUpdateReload,
requestPWAUpdate,
watchForPWAUpdate,
} from './pwaUpdate';

class MockServiceWorker extends EventTarget {
state: ServiceWorkerState = 'installing';
postMessage = jest.fn();
}

class MockRegistration extends EventTarget {
installing: MockServiceWorker | null = null;
waiting: MockServiceWorker | null = null;
}

describe('watchForPWAUpdate', () => {
it('reports an existing waiting update when a worker already controls the page', () => {
const registration = new MockRegistration();
registration.waiting = new MockServiceWorker();
const onUpdateReady = jest.fn();

watchForPWAUpdate(registration as unknown as ServiceWorkerRegistration, {
getController: () => new MockServiceWorker() as unknown as ServiceWorker,
onUpdateReady,
});

expect(onUpdateReady).toHaveBeenCalledTimes(1);
});

it('reports a newly installed update', () => {
const registration = new MockRegistration();
const installingWorker = new MockServiceWorker();
registration.installing = installingWorker;
const onUpdateReady = jest.fn();

watchForPWAUpdate(registration as unknown as ServiceWorkerRegistration, {
getController: () => new MockServiceWorker() as unknown as ServiceWorker,
onUpdateReady,
});

registration.dispatchEvent(new Event('updatefound'));
registration.waiting = installingWorker;
installingWorker.state = 'installed';
installingWorker.dispatchEvent(new Event('statechange'));

expect(onUpdateReady).toHaveBeenCalledTimes(1);
});
});

describe('requestPWAUpdate', () => {
it('asks a waiting worker to activate', () => {
const worker = new MockServiceWorker();
const updated = requestPWAUpdate({ waiting: worker });

expect(updated).toBe(true);
expect(worker.postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' });
});

it('does nothing when no update is waiting', () => {
const updated = requestPWAUpdate({ waiting: null });

expect(updated).toBe(false);
});
});

describe('the PWA reload guard', () => {
it('blocks only a second reload during the guard window', () => {
const storage = new Map<string, string>();
const sessionStorage = {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
};

markPWAUpdateReload(sessionStorage, 1_000);

expect(hasRecentPWAUpdateReload(sessionStorage, 20_000)).toBe(true);
expect(hasRecentPWAUpdateReload(sessionStorage, 40_000)).toBe(false);
});
});
82 changes: 82 additions & 0 deletions src/hooks/useRegisterSW/pwaUpdate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
export const PWA_UPDATE_RELOAD_GUARD_MS = 30_000;

const PWA_UPDATE_LAST_RELOAD_AT_KEY = 'pwa-update-last-reload-at';

type UpdateRegistration = Pick<
ServiceWorkerRegistration,
'installing' | 'removeEventListener' | 'waiting'
> &
Pick<EventTarget, 'addEventListener'>;

interface WatchForPWAUpdateOptions {
getController: () => ServiceWorker | null;
onUpdateReady: () => void;
}

export function watchForPWAUpdate(
registration: UpdateRegistration,
{ getController, onUpdateReady }: WatchForPWAUpdateOptions,
) {
let installingWorker: ServiceWorker | null = null;
let onWorkerStateChange: (() => void) | undefined;

const reportWaitingUpdate = () => {
if (getController() && registration.waiting) {
onUpdateReady();
}
};

const onUpdateFound = () => {
const worker = registration.installing;
if (!worker) {
return;
}

if (installingWorker && onWorkerStateChange) {
installingWorker.removeEventListener('statechange', onWorkerStateChange);
}
installingWorker = worker;
const onStateChange = () => {
if (worker.state === 'installed') {
reportWaitingUpdate();
}
};
onWorkerStateChange = onStateChange;
worker.addEventListener('statechange', onStateChange);
};

registration.addEventListener('updatefound', onUpdateFound);
reportWaitingUpdate();

return () => {
registration.removeEventListener('updatefound', onUpdateFound);
if (installingWorker && onWorkerStateChange) {
installingWorker.removeEventListener('statechange', onWorkerStateChange);
}
};
}

interface WaitingServiceWorker {
postMessage: ServiceWorker['postMessage'];
}

type PWAUpdateStorage = Pick<Storage, 'getItem' | 'setItem'>;

export function requestPWAUpdate(registration: { waiting: WaitingServiceWorker | null }) {
const worker = registration.waiting;
if (!worker) {
return false;
}

worker.postMessage({ type: 'SKIP_WAITING' });
return true;
}

export function hasRecentPWAUpdateReload(storage: PWAUpdateStorage, now: number) {
const lastReloadAt = Number(storage.getItem(PWA_UPDATE_LAST_RELOAD_AT_KEY));
return Number.isFinite(lastReloadAt) && now - lastReloadAt < PWA_UPDATE_RELOAD_GUARD_MS;
}

export function markPWAUpdateReload(storage: PWAUpdateStorage, now: number) {
storage.setItem(PWA_UPDATE_LAST_RELOAD_AT_KEY, String(now));
}
62 changes: 46 additions & 16 deletions src/hooks/useRegisterSW/useRegisterSW.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,73 @@
import { registerSW } from 'virtual:pwa-register';
import { useEffect, useRef, useState } from 'react';
import {
hasRecentPWAUpdateReload,
markPWAUpdateReload,
requestPWAUpdate,
watchForPWAUpdate,
} from './pwaUpdate';
import { startPWAUpdatePolling } from './pwaUpdatePolling';

export function usePWAUpdate() {
const [updateAvailable, setUpdateAvailable] = useState(false);
const updateSWRef = useRef<(reloadPage?: boolean) => Promise<void>>();
const registrationRef = useRef<ServiceWorkerRegistration>();
const stopUpdatePollingRef = useRef<() => void>();

useEffect(() => {
if (!import.meta.env.PROD) {
return;
}

updateSWRef.current = registerSW({
immediate: true,
onNeedRefresh() {
setUpdateAvailable(true);
},
onRegisteredSW(_swUrl, registration) {
if (!registration) {
let active = true;
let stopWatchingForUpdates: (() => void) | undefined;

void navigator.serviceWorker
.register('/sw.js', {
updateViaCache: 'none',
})
.then((registration) => {
if (!active) {
return;
}

registrationRef.current = registration;
stopWatchingForUpdates = watchForPWAUpdate(registration, {
getController: () => navigator.serviceWorker.controller,
onUpdateReady: () => setUpdateAvailable(true),
});
stopUpdatePollingRef.current?.();
stopUpdatePollingRef.current = startPWAUpdatePolling(registration);
},
onOfflineReady() {
// optionally notify
},
});
})
.catch(() => undefined);

return () => {
active = false;
stopWatchingForUpdates?.();
stopUpdatePollingRef.current?.();
};
}, []);

const updateSW = async (reloadPage = true) => {
const updateSW = async () => {
setUpdateAvailable(false);
await updateSWRef.current?.(reloadPage);
const registration = registrationRef.current;
if (!registration) {
return;
}

try {
await registration.update();
} catch {
setUpdateAvailable(true);
return;
}
if (!registration.waiting || hasRecentPWAUpdateReload(sessionStorage, Date.now())) {
return;
}

markPWAUpdateReload(sessionStorage, Date.now());
navigator.serviceWorker.addEventListener('controllerchange', () => window.location.reload(), {
once: true,
});
requestPWAUpdate(registration);
};

return { updateAvailable, updateSW };
Expand Down
2 changes: 1 addition & 1 deletion src/layouts/RootLayout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function RootLayout() {
return (
<div className="flex flex-col flex-1 full-viewport-height overflow-hidden bg-app">
<Header />
{updateAvailable && <AppUpdatePrompt onUpdate={() => updateSW(true)} />}
{updateAvailable && <AppUpdatePrompt onUpdate={updateSW} />}
<main className="flex flex-1 flex-col w-full items-center overflow-hidden">
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Outlet />
Expand Down
2 changes: 2 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ export default defineConfig(({ mode }) => ({
viteTsconfigPaths(),
ViteYaml(),
VitePWA({
injectRegister: false,
registerType: 'prompt',
workbox: {
clientsClaim: true,
importScripts: ['notification-sw.js'],
},
}),
Expand Down
Loading