import { useSyncExternalStore } from 'react';
import { SimpleEventEmitter } from '@mary-ext/simple-event-emitter';
/**
* lifecycle status of the service worker from the page's point of view:
*
* - `uninstalled` — no worker is registered for this page. offline support is not active.
* - `installing` — a first-ever install is downloading.
* - `installed` — a worker is active and no update is pending.
* - `update_installing` — a new worker is downloading or installing over the active one.
* - `update_ready` — a new worker has installed and is waiting to activate. call
* {@link applyServiceWorkerUpdate} to activate.
*/
export type ServiceWorkerStatus =
| 'installed'
| 'installing'
| 'uninstalled'
| 'updateInstalling'
| 'updateReady';
const events = new SimpleEventEmitter<[ServiceWorkerStatus]>();
let status: ServiceWorkerStatus = 'uninstalled';
let registration: ServiceWorkerRegistration | undefined;
const setStatus = (next: ServiceWorkerStatus) => {
if (next === status) {
return;
}
status = next;
events.emit(status);
};
// derive status from the registration so progress survives component unmounts.
const reconcile = () => {
const reg = registration;
if (reg?.waiting) {
setStatus('updateReady');
} else if (reg?.installing) {
setStatus(reg.active ? 'updateInstalling' : 'installing');
} else {
setStatus(reg?.active ? 'installed' : 'uninstalled');
}
};
/** @returns the current service-worker status */
export const getServiceWorkerStatus = () => status;
/** subscribes to service-worker status changes. */
export const subscribeServiceWorkerStatus = (listener: (status: ServiceWorkerStatus) => void) =>
events.subscribe(listener);
/** returns the live {@link ServiceWorkerStatus} in a React hook. */
export const useServiceWorkerStatus = () =>
useSyncExternalStore(subscribeServiceWorkerStatus, getServiceWorkerStatus);
const attach = (reg: ServiceWorkerRegistration) => {
registration = reg;
reconcile();
// an install activating and claiming the page, or an update taking control, both surface as a
// controller change.
navigator.serviceWorker.addEventListener('controllerchange', reconcile);
// reconcile against the registration's current state, not just future events: a reload can land
// after `updatefound` already fired, leaving a worker parked in `waiting` or still `installing`.
if (reg.installing) {
reg.installing.addEventListener('statechange', reconcile);
}
reg.addEventListener('updatefound', () => {
reconcile();
reg.installing?.addEventListener('statechange', reconcile);
});
};
/** begin tracking an already-registered service worker without installing one. call once at startup. */
export const initServiceWorker = () => {
if (registration || !('serviceWorker' in navigator)) {
return;
}
// reflect an already-controlling worker before the async lookup resolves
if (navigator.serviceWorker.controller) {
setStatus('installed');
}
navigator.serviceWorker.getRegistration().then(
(reg) => {
if (reg && !registration) {
attach(reg);
}
},
(err) => {
console.error('service worker lookup failed', err);
},
);
};
/**
* install the service worker and trigger the precache download. call this from an explicit user action. no-op
* if already registered.
*/
export const registerServiceWorker = () => {
// dev builds emit no `/sw.js`, so registering would 404. keep the settings row visible but inert.
if (import.meta.env.DEV || registration || !('serviceWorker' in navigator)) {
return;
}
navigator.serviceWorker.register('/sw.js').then(attach, (err) => {
console.error('service worker registration failed', err);
});
};
/** activates a waiting service worker and reloads after it takes control. */
export const applyServiceWorkerUpdate = () => {
const waiting = registration?.waiting;
if (!waiting) {
return;
}
let reloaded = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
// `controllerchange` can fire more than once; guard against a reload loop.
if (reloaded) {
return;
}
reloaded = true;
window.location.reload();
});
// oxlint-disable-next-line unicorn/require-post-message-target-origin -- ServiceWorker, not a Window: its second argument is a transfer list
waiting.postMessage({ type: 'SKIP_WAITING' });
};