Skip to content

Commit 2907637

Browse files
committed
feat(pwa): auto-apply service-worker updates when safe
Detect a waiting service worker and apply it automatically at the next safe moment instead of only on the manual "update available" banner. Auto-apply fires only while the page is hidden and a reload would lose nothing: not navigating/rerouting, no in-flight mutation, no in-page area download, no unsaved text, online, and outside a loop-guard cooldown. The banner stays as a foreground fallback (after a 30s grace) for when no safe moment arrives. Gate logic lives in apps/web/src/lib/swAutoUpdate.ts (pure + unit tested); SwUpdateNotice wires it via visibilitychange/online listeners.
1 parent f35f950 commit 2907637

3 files changed

Lines changed: 216 additions & 3 deletions

File tree

apps/web/src/components/pwa/SwUpdateNotice.tsx

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,78 @@
33
import Alert from "@mui/material/Alert";
44
import Button from "@mui/material/Button";
55
import Snackbar from "@mui/material/Snackbar";
6+
import { useNavigationStore } from "@openmapx/core";
67
import type { Serwist } from "@serwist/window";
8+
import { useQueryClient } from "@tanstack/react-query";
79
import { useTranslations } from "next-intl";
810
import { useEffect, useRef, useState } from "react";
11+
import {
12+
hasActiveAreaDownload,
13+
hasUnsavedTextEntry,
14+
isSafeToAutoReload,
15+
markAutoReloaded,
16+
msSinceLastAutoReload,
17+
} from "@/lib/swAutoUpdate";
18+
19+
const BANNER_GRACE_MS = 30_000;
920

1021
export function SwUpdateNotice() {
1122
const t = useTranslations("pwa");
23+
const queryClient = useQueryClient();
1224
const [updateReady, setUpdateReady] = useState(false);
25+
const [visible, setVisible] = useState(true);
26+
const [graceElapsed, setGraceElapsed] = useState(false);
1327
const swRef = useRef<Serwist | null>(null);
28+
const updateReadyRef = useRef(false);
29+
30+
useEffect(() => {
31+
setVisible(document.visibilityState === "visible");
32+
}, []);
1433

1534
useEffect(() => {
1635
if (process.env.NODE_ENV !== "production") return;
1736
if (typeof window === "undefined") return;
1837
if (!("serviceWorker" in navigator)) return;
1938

2039
let cancelled = false;
40+
let graceTimer: ReturnType<typeof setTimeout> | null = null;
41+
42+
const handleVisibilityChange = () => {
43+
setVisible(document.visibilityState === "visible");
44+
attemptAutoApply();
45+
};
46+
47+
const handleOnline = () => {
48+
attemptAutoApply();
49+
};
50+
51+
const attemptAutoApply = () => {
52+
if (!updateReadyRef.current) return;
53+
if (document.visibilityState !== "hidden") return;
54+
const safe = isSafeToAutoReload({
55+
online: navigator.onLine,
56+
navStatus: useNavigationStore.getState().status,
57+
mutationCount: queryClient.isMutating(),
58+
hasActiveDownload: hasActiveAreaDownload(),
59+
hasUnsavedText: hasUnsavedTextEntry(),
60+
msSinceLastAutoReload: msSinceLastAutoReload(),
61+
});
62+
if (!safe) return;
63+
markAutoReloaded();
64+
swRef.current?.messageSkipWaiting();
65+
};
2166

2267
void import("@serwist/window").then((mod) => {
2368
if (cancelled) return;
2469
const sw = new mod.Serwist("/sw.js", { scope: "/" });
2570

2671
sw.addEventListener("waiting", () => {
72+
updateReadyRef.current = true;
2773
setUpdateReady(true);
74+
graceTimer = setTimeout(() => setGraceElapsed(true), BANNER_GRACE_MS);
75+
document.addEventListener("visibilitychange", handleVisibilityChange);
76+
window.addEventListener("online", handleOnline);
77+
attemptAutoApply();
2878
});
2979

3080
sw.addEventListener("controlling", (event) => {
@@ -45,8 +95,11 @@ export function SwUpdateNotice() {
4595

4696
return () => {
4797
cancelled = true;
98+
document.removeEventListener("visibilitychange", handleVisibilityChange);
99+
window.removeEventListener("online", handleOnline);
100+
if (graceTimer !== null) clearTimeout(graceTimer);
48101
};
49-
}, []);
102+
}, [queryClient]);
50103

51104
const handleReload = () => {
52105
const sw = swRef.current;
@@ -59,7 +112,7 @@ export function SwUpdateNotice() {
59112

60113
return (
61114
<Snackbar
62-
open={updateReady}
115+
open={updateReady && visible && graceElapsed}
63116
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
64117
sx={{ zIndex: 1500, mb: "var(--omx-safe-bottom)" }}
65118
>
@@ -71,7 +124,12 @@ export function SwUpdateNotice() {
71124
{t("reload")}
72125
</Button>
73126
}
74-
sx={{ width: "100%" }}
127+
sx={{
128+
width: "100%",
129+
bgcolor: "primary.main",
130+
color: "primary.contrastText",
131+
"& .MuiAlert-icon": { color: "inherit" },
132+
}}
75133
>
76134
{t("updateAvailable")}
77135
</Alert>
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
2+
import {
3+
AUTO_RELOAD_COOLDOWN_MS,
4+
type AutoUpdateSafetyInputs,
5+
hasUnsavedTextEntry,
6+
isSafeToAutoReload,
7+
} from "./swAutoUpdate";
8+
9+
const ALL_CLEAR: AutoUpdateSafetyInputs = {
10+
online: true,
11+
navStatus: "idle",
12+
mutationCount: 0,
13+
hasActiveDownload: false,
14+
hasUnsavedText: false,
15+
msSinceLastAutoReload: Number.POSITIVE_INFINITY,
16+
};
17+
18+
describe("isSafeToAutoReload", () => {
19+
it("returns true when all conditions are clear", () => {
20+
expect(isSafeToAutoReload(ALL_CLEAR)).toBe(true);
21+
});
22+
23+
it("returns false when offline", () => {
24+
expect(isSafeToAutoReload({ ...ALL_CLEAR, online: false })).toBe(false);
25+
});
26+
27+
it("returns false when navigating", () => {
28+
expect(isSafeToAutoReload({ ...ALL_CLEAR, navStatus: "navigating" })).toBe(false);
29+
});
30+
31+
it("returns false when rerouting", () => {
32+
expect(isSafeToAutoReload({ ...ALL_CLEAR, navStatus: "rerouting" })).toBe(false);
33+
});
34+
35+
it("returns false when there is an in-flight mutation", () => {
36+
expect(isSafeToAutoReload({ ...ALL_CLEAR, mutationCount: 1 })).toBe(false);
37+
});
38+
39+
it("returns false when an area download is active", () => {
40+
expect(isSafeToAutoReload({ ...ALL_CLEAR, hasActiveDownload: true })).toBe(false);
41+
});
42+
43+
it("returns false when there is unsaved text", () => {
44+
expect(isSafeToAutoReload({ ...ALL_CLEAR, hasUnsavedText: true })).toBe(false);
45+
});
46+
47+
it("returns false when last auto-reload is within the cooldown window", () => {
48+
expect(
49+
isSafeToAutoReload({
50+
...ALL_CLEAR,
51+
msSinceLastAutoReload: AUTO_RELOAD_COOLDOWN_MS - 1,
52+
}),
53+
).toBe(false);
54+
});
55+
56+
it("returns true when navStatus is idle (all else clear)", () => {
57+
expect(isSafeToAutoReload({ ...ALL_CLEAR, navStatus: "idle" })).toBe(true);
58+
});
59+
60+
it("returns true when navStatus is arrived (all else clear)", () => {
61+
expect(isSafeToAutoReload({ ...ALL_CLEAR, navStatus: "arrived" })).toBe(true);
62+
});
63+
});
64+
65+
describe("hasUnsavedTextEntry", () => {
66+
let input: HTMLInputElement;
67+
68+
beforeEach(() => {
69+
input = document.createElement("input");
70+
document.body.appendChild(input);
71+
});
72+
73+
afterEach(() => {
74+
input.blur();
75+
document.body.removeChild(input);
76+
});
77+
78+
it("returns false when an input is focused but empty", () => {
79+
input.focus();
80+
input.value = "";
81+
expect(hasUnsavedTextEntry()).toBe(false);
82+
});
83+
84+
it("returns true when a focused input has a non-empty value", () => {
85+
input.focus();
86+
input.value = "x";
87+
expect(hasUnsavedTextEntry()).toBe(true);
88+
});
89+
90+
it("returns false after the input is blurred and removed", () => {
91+
input.focus();
92+
input.value = "x";
93+
input.blur();
94+
expect(hasUnsavedTextEntry()).toBe(false);
95+
});
96+
});

apps/web/src/lib/swAutoUpdate.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { NavStatus } from "@openmapx/core";
2+
import { listAreas } from "@/lib/offlineAreas/storage";
3+
4+
export const AUTO_RELOAD_COOLDOWN_MS = 2 * 60 * 1000;
5+
const LAST_AUTO_RELOAD_KEY = "omx:sw-last-auto-reload";
6+
7+
export interface AutoUpdateSafetyInputs {
8+
online: boolean;
9+
navStatus: NavStatus;
10+
mutationCount: number;
11+
hasActiveDownload: boolean;
12+
hasUnsavedText: boolean;
13+
msSinceLastAutoReload: number; // Number.POSITIVE_INFINITY if never
14+
}
15+
16+
export function isSafeToAutoReload(i: AutoUpdateSafetyInputs): boolean {
17+
if (!i.online) return false;
18+
if (i.navStatus === "navigating" || i.navStatus === "rerouting") return false;
19+
if (i.mutationCount > 0) return false;
20+
if (i.hasActiveDownload) return false;
21+
if (i.hasUnsavedText) return false;
22+
if (i.msSinceLastAutoReload < AUTO_RELOAD_COOLDOWN_MS) return false;
23+
return true;
24+
}
25+
26+
export function hasUnsavedTextEntry(): boolean {
27+
const el = typeof document === "undefined" ? null : document.activeElement;
28+
if (!el) return false;
29+
const tag = el.tagName;
30+
if (tag === "INPUT" || tag === "TEXTAREA") {
31+
return ((el as HTMLInputElement | HTMLTextAreaElement).value ?? "").length > 0;
32+
}
33+
if ((el as HTMLElement).isContentEditable) {
34+
return ((el as HTMLElement).textContent ?? "").length > 0;
35+
}
36+
return false;
37+
}
38+
39+
export function hasActiveAreaDownload(): boolean {
40+
try {
41+
return listAreas().some((a) => a.status === "downloading");
42+
} catch {
43+
return false;
44+
}
45+
}
46+
47+
export function msSinceLastAutoReload(): number {
48+
if (typeof sessionStorage === "undefined") return Number.POSITIVE_INFINITY;
49+
const raw = sessionStorage.getItem(LAST_AUTO_RELOAD_KEY);
50+
if (!raw) return Number.POSITIVE_INFINITY;
51+
const then = Number.parseInt(raw, 10);
52+
if (Number.isNaN(then)) return Number.POSITIVE_INFINITY;
53+
return Date.now() - then;
54+
}
55+
56+
export function markAutoReloaded(): void {
57+
if (typeof sessionStorage === "undefined") return;
58+
sessionStorage.setItem(LAST_AUTO_RELOAD_KEY, String(Date.now()));
59+
}

0 commit comments

Comments
 (0)