FEAT: PWA relevant hook
soon before may.
That would be sooooo good 💯💯💯
Maybe you can team up with @DuCanhGH from https://serwist.pages.dev/
I previously used it like this. Directly getting the service worker information to do some things. @DuCanhGH do you have any suggestions? I might not be so familiar with service workers right now, should I encapsulate @serwist/window?
import { useState, useEffect } from "react";
interface PwaStatus {
isPwa: boolean;
isStandalone: boolean;
isInstallable: boolean;
displayMode: string;
installPromptEvent: any | null;
promptInstall: () => Promise<boolean>;
}
interface ServiceWorkerStatus {
isSupported: boolean;
isRegistered: boolean;
registration: ServiceWorkerRegistration | null;
workerState: string | null;
error: string | null;
}
interface UsePwaStatusResult {
pwa: PwaStatus;
serviceWorker: ServiceWorkerStatus;
}
interface ExtendedNavigator extends Navigator {
standalone?: boolean;
}
/**
* Hook to detect current PWA and Service Worker status
* @returns PWA and Service Worker status information
*/
export const usePwaStatus = (): UsePwaStatusResult => {
const [pwaStatus, setPwaStatus] = useState<Omit<PwaStatus, "promptInstall">>({
isPwa: false,
isStandalone: false,
isInstallable: false,
displayMode: "browser",
installPromptEvent: null,
});
const [swStatus, setSwStatus] = useState<ServiceWorkerStatus>({
isSupported: false,
isRegistered: false,
registration: null,
workerState: null,
error: null,
});
useEffect(() => {
const isHttps = window.location.protocol === "https:";
const checkPwaStatus = (): void => {
const detectDisplayMode = (): string => {
const modes = ["standalone", "fullscreen", "minimal-ui", "browser"];
let currentMode = "browser";
for (const mode of modes) {
if (window.matchMedia(`(display-mode: ${mode})`).matches) {
currentMode = mode;
break;
}
}
const extendedNavigator = navigator as ExtendedNavigator;
if (extendedNavigator.standalone) {
currentMode = "standalone";
}
return currentMode;
};
const displayMode = detectDisplayMode();
const extendedNavigator = navigator as ExtendedNavigator;
const isStandalone =
displayMode === "standalone" ||
displayMode === "fullscreen" ||
extendedNavigator.standalone === true;
const hasServiceWorker = "serviceWorker" in navigator;
const hasManifest = Array.from(document.querySelectorAll("link")).some(
(link) => link.rel === "manifest"
);
const isPwa = hasServiceWorker && hasManifest && isHttps;
const isInstallable = "BeforeInstallPromptEvent" in window;
setPwaStatus({
isPwa,
isStandalone,
isInstallable,
displayMode,
installPromptEvent: null,
});
};
const checkServiceWorker = async (): Promise<void> => {
const updatePwaStatus = (isRegistered: boolean): void => {
setPwaStatus((prev) => {
const hasManifest = Array.from(
document.querySelectorAll("link")
).some((link) => link.rel === "manifest");
const isPwa = isRegistered && hasManifest && isHttps;
return {
...prev,
isPwa,
};
});
};
if (!("serviceWorker" in navigator)) {
setSwStatus((prev) => ({ ...prev, isSupported: false }));
return;
}
setSwStatus((prev) => ({ ...prev, isSupported: true }));
try {
const registrations = await navigator.serviceWorker.getRegistrations();
const isRegistered = registrations.length > 0;
const registration = isRegistered ? registrations[0] : null;
let workerState: string | null = null;
if (registration) {
if (registration.installing) workerState = "installing";
else if (registration.waiting) workerState = "waiting";
else if (registration.active) workerState = "active";
}
setSwStatus((prev) => ({
...prev,
isRegistered,
registration,
workerState,
}));
updatePwaStatus(isRegistered);
if (registration) {
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener("statechange", () => {
let updatedState: string | null = null;
if (registration.installing) updatedState = "installing";
else if (registration.waiting) updatedState = "waiting";
else if (registration.active) updatedState = "active";
setSwStatus((prev) => ({
...prev,
workerState: updatedState,
}));
});
}
});
}
} catch (error) {
setSwStatus((prev) => ({
...prev,
error:
error instanceof Error
? error.message
: "Error checking Service Worker status",
}));
}
};
checkPwaStatus();
checkServiceWorker();
const displayModeHandler = (): void => {
checkPwaStatus();
};
const modeMediaQueries = [
"standalone",
"fullscreen",
"minimal-ui",
"browser",
].map((mode) => {
const mq = window.matchMedia(`(display-mode: ${mode})`);
mq.addEventListener("change", displayModeHandler);
return mq;
});
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
const beforeInstallPromptHandler = (e: Event): void => {
e.preventDefault();
setPwaStatus((prev) => ({
...prev,
installPromptEvent: e as BeforeInstallPromptEvent,
isInstallable: true,
}));
};
window.addEventListener("beforeinstallprompt", beforeInstallPromptHandler);
const appInstalledHandler = (): void => {
setPwaStatus((prev) => ({
...prev,
isPwa: true,
isStandalone: true,
installPromptEvent: null,
}));
};
window.addEventListener("appinstalled", appInstalledHandler);
return () => {
modeMediaQueries.forEach((mq) => {
mq.removeEventListener("change", displayModeHandler);
});
window.removeEventListener(
"beforeinstallprompt",
beforeInstallPromptHandler
);
window.removeEventListener("appinstalled", appInstalledHandler);
};
}, []);
/**
* Trigger PWA installation prompt
* @returns Promise indicating whether installation was successful
*/
const promptInstall = async (): Promise<boolean> => {
const { installPromptEvent } = pwaStatus;
if (!installPromptEvent) {
return false;
}
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
const event = installPromptEvent as BeforeInstallPromptEvent;
await event.prompt();
const choiceResult = await event.userChoice;
setPwaStatus((prev) => ({
...prev,
installPromptEvent: null,
}));
return choiceResult.outcome === "accepted";
};
return {
pwa: {
...pwaStatus,
promptInstall,
},
serviceWorker: swStatus,
};
};
@childrentime I'd say that Serwist won't be of much help here, since @serwist/window doesn't have much utility besides being used with service workers written using Serwist. This looks to me a great React hook though!