Skip to content

Commit a3f5b0b

Browse files
authored
Merge pull request #90 from coder13/beta
Deploy beta to production
2 parents 6d71b38 + dbd849e commit a3f5b0b

5 files changed

Lines changed: 212 additions & 17 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import {
2+
hasRecentPWAUpdateReload,
3+
markPWAUpdateReload,
4+
requestPWAUpdate,
5+
watchForPWAUpdate,
6+
} from './pwaUpdate';
7+
8+
class MockServiceWorker extends EventTarget {
9+
state: ServiceWorkerState = 'installing';
10+
postMessage = jest.fn();
11+
}
12+
13+
class MockRegistration extends EventTarget {
14+
installing: MockServiceWorker | null = null;
15+
waiting: MockServiceWorker | null = null;
16+
}
17+
18+
describe('watchForPWAUpdate', () => {
19+
it('reports an existing waiting update when a worker already controls the page', () => {
20+
const registration = new MockRegistration();
21+
registration.waiting = new MockServiceWorker();
22+
const onUpdateReady = jest.fn();
23+
24+
watchForPWAUpdate(registration as unknown as ServiceWorkerRegistration, {
25+
getController: () => new MockServiceWorker() as unknown as ServiceWorker,
26+
onUpdateReady,
27+
});
28+
29+
expect(onUpdateReady).toHaveBeenCalledTimes(1);
30+
});
31+
32+
it('reports a newly installed update', () => {
33+
const registration = new MockRegistration();
34+
const installingWorker = new MockServiceWorker();
35+
registration.installing = installingWorker;
36+
const onUpdateReady = jest.fn();
37+
38+
watchForPWAUpdate(registration as unknown as ServiceWorkerRegistration, {
39+
getController: () => new MockServiceWorker() as unknown as ServiceWorker,
40+
onUpdateReady,
41+
});
42+
43+
registration.dispatchEvent(new Event('updatefound'));
44+
registration.waiting = installingWorker;
45+
installingWorker.state = 'installed';
46+
installingWorker.dispatchEvent(new Event('statechange'));
47+
48+
expect(onUpdateReady).toHaveBeenCalledTimes(1);
49+
});
50+
});
51+
52+
describe('requestPWAUpdate', () => {
53+
it('asks a waiting worker to activate', () => {
54+
const worker = new MockServiceWorker();
55+
const updated = requestPWAUpdate({ waiting: worker });
56+
57+
expect(updated).toBe(true);
58+
expect(worker.postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' });
59+
});
60+
61+
it('does nothing when no update is waiting', () => {
62+
const updated = requestPWAUpdate({ waiting: null });
63+
64+
expect(updated).toBe(false);
65+
});
66+
});
67+
68+
describe('the PWA reload guard', () => {
69+
it('blocks only a second reload during the guard window', () => {
70+
const storage = new Map<string, string>();
71+
const sessionStorage = {
72+
getItem: (key: string) => storage.get(key) ?? null,
73+
setItem: (key: string, value: string) => storage.set(key, value),
74+
};
75+
76+
markPWAUpdateReload(sessionStorage, 1_000);
77+
78+
expect(hasRecentPWAUpdateReload(sessionStorage, 20_000)).toBe(true);
79+
expect(hasRecentPWAUpdateReload(sessionStorage, 40_000)).toBe(false);
80+
});
81+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
export const PWA_UPDATE_RELOAD_GUARD_MS = 30_000;
2+
3+
const PWA_UPDATE_LAST_RELOAD_AT_KEY = 'pwa-update-last-reload-at';
4+
5+
type UpdateRegistration = Pick<
6+
ServiceWorkerRegistration,
7+
'installing' | 'removeEventListener' | 'waiting'
8+
> &
9+
Pick<EventTarget, 'addEventListener'>;
10+
11+
interface WatchForPWAUpdateOptions {
12+
getController: () => ServiceWorker | null;
13+
onUpdateReady: () => void;
14+
}
15+
16+
export function watchForPWAUpdate(
17+
registration: UpdateRegistration,
18+
{ getController, onUpdateReady }: WatchForPWAUpdateOptions,
19+
) {
20+
let installingWorker: ServiceWorker | null = null;
21+
let onWorkerStateChange: (() => void) | undefined;
22+
23+
const reportWaitingUpdate = () => {
24+
if (getController() && registration.waiting) {
25+
onUpdateReady();
26+
}
27+
};
28+
29+
const onUpdateFound = () => {
30+
const worker = registration.installing;
31+
if (!worker) {
32+
return;
33+
}
34+
35+
if (installingWorker && onWorkerStateChange) {
36+
installingWorker.removeEventListener('statechange', onWorkerStateChange);
37+
}
38+
installingWorker = worker;
39+
const onStateChange = () => {
40+
if (worker.state === 'installed') {
41+
reportWaitingUpdate();
42+
}
43+
};
44+
onWorkerStateChange = onStateChange;
45+
worker.addEventListener('statechange', onStateChange);
46+
};
47+
48+
registration.addEventListener('updatefound', onUpdateFound);
49+
reportWaitingUpdate();
50+
51+
return () => {
52+
registration.removeEventListener('updatefound', onUpdateFound);
53+
if (installingWorker && onWorkerStateChange) {
54+
installingWorker.removeEventListener('statechange', onWorkerStateChange);
55+
}
56+
};
57+
}
58+
59+
interface WaitingServiceWorker {
60+
postMessage: ServiceWorker['postMessage'];
61+
}
62+
63+
type PWAUpdateStorage = Pick<Storage, 'getItem' | 'setItem'>;
64+
65+
export function requestPWAUpdate(registration: { waiting: WaitingServiceWorker | null }) {
66+
const worker = registration.waiting;
67+
if (!worker) {
68+
return false;
69+
}
70+
71+
worker.postMessage({ type: 'SKIP_WAITING' });
72+
return true;
73+
}
74+
75+
export function hasRecentPWAUpdateReload(storage: PWAUpdateStorage, now: number) {
76+
const lastReloadAt = Number(storage.getItem(PWA_UPDATE_LAST_RELOAD_AT_KEY));
77+
return Number.isFinite(lastReloadAt) && now - lastReloadAt < PWA_UPDATE_RELOAD_GUARD_MS;
78+
}
79+
80+
export function markPWAUpdateReload(storage: PWAUpdateStorage, now: number) {
81+
storage.setItem(PWA_UPDATE_LAST_RELOAD_AT_KEY, String(now));
82+
}

‎src/hooks/useRegisterSW/useRegisterSW.ts‎

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,73 @@
1-
import { registerSW } from 'virtual:pwa-register';
21
import { useEffect, useRef, useState } from 'react';
2+
import {
3+
hasRecentPWAUpdateReload,
4+
markPWAUpdateReload,
5+
requestPWAUpdate,
6+
watchForPWAUpdate,
7+
} from './pwaUpdate';
38
import { startPWAUpdatePolling } from './pwaUpdatePolling';
49

510
export function usePWAUpdate() {
611
const [updateAvailable, setUpdateAvailable] = useState(false);
7-
const updateSWRef = useRef<(reloadPage?: boolean) => Promise<void>>();
12+
const registrationRef = useRef<ServiceWorkerRegistration>();
813
const stopUpdatePollingRef = useRef<() => void>();
914

1015
useEffect(() => {
1116
if (!import.meta.env.PROD) {
1217
return;
1318
}
1419

15-
updateSWRef.current = registerSW({
16-
immediate: true,
17-
onNeedRefresh() {
18-
setUpdateAvailable(true);
19-
},
20-
onRegisteredSW(_swUrl, registration) {
21-
if (!registration) {
20+
let active = true;
21+
let stopWatchingForUpdates: (() => void) | undefined;
22+
23+
void navigator.serviceWorker
24+
.register('/sw.js', {
25+
updateViaCache: 'none',
26+
})
27+
.then((registration) => {
28+
if (!active) {
2229
return;
2330
}
2431

32+
registrationRef.current = registration;
33+
stopWatchingForUpdates = watchForPWAUpdate(registration, {
34+
getController: () => navigator.serviceWorker.controller,
35+
onUpdateReady: () => setUpdateAvailable(true),
36+
});
2537
stopUpdatePollingRef.current?.();
2638
stopUpdatePollingRef.current = startPWAUpdatePolling(registration);
27-
},
28-
onOfflineReady() {
29-
// optionally notify
30-
},
31-
});
39+
})
40+
.catch(() => undefined);
3241

3342
return () => {
43+
active = false;
44+
stopWatchingForUpdates?.();
3445
stopUpdatePollingRef.current?.();
3546
};
3647
}, []);
3748

38-
const updateSW = async (reloadPage = true) => {
49+
const updateSW = async () => {
3950
setUpdateAvailable(false);
40-
await updateSWRef.current?.(reloadPage);
51+
const registration = registrationRef.current;
52+
if (!registration) {
53+
return;
54+
}
55+
56+
try {
57+
await registration.update();
58+
} catch {
59+
setUpdateAvailable(true);
60+
return;
61+
}
62+
if (!registration.waiting || hasRecentPWAUpdateReload(sessionStorage, Date.now())) {
63+
return;
64+
}
65+
66+
markPWAUpdateReload(sessionStorage, Date.now());
67+
navigator.serviceWorker.addEventListener('controllerchange', () => window.location.reload(), {
68+
once: true,
69+
});
70+
requestPWAUpdate(registration);
4171
};
4272

4373
return { updateAvailable, updateSW };

‎src/layouts/RootLayout/index.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export function RootLayout() {
1010
return (
1111
<div className="flex flex-col flex-1 full-viewport-height overflow-hidden bg-app">
1212
<Header />
13-
{updateAvailable && <AppUpdatePrompt onUpdate={() => updateSW(true)} />}
13+
{updateAvailable && <AppUpdatePrompt onUpdate={updateSW} />}
1414
<main className="flex flex-1 flex-col w-full items-center overflow-hidden">
1515
<ErrorBoundary FallbackComponent={ErrorFallback}>
1616
<Outlet />

‎vite.config.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,10 @@ export default defineConfig(({ mode }) => ({
4343
viteTsconfigPaths(),
4444
ViteYaml(),
4545
VitePWA({
46+
injectRegister: false,
4647
registerType: 'prompt',
4748
workbox: {
49+
clientsClaim: true,
4850
importScripts: ['notification-sw.js'],
4951
},
5052
}),

0 commit comments

Comments
 (0)