Skip to content

Commit 9d818b5

Browse files
committed
fix: Correctly handle transition from offline back to online
Previously we were relying on a fetch() succeeding to tell us whether we were back online, but there's no guarantee of a fetch() being attempted (so we stayed "offline" longer than we needed to)
1 parent 67073d5 commit 9d818b5

3 files changed

Lines changed: 119 additions & 15 deletions

File tree

public/service-worker.js

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,25 +77,49 @@ function addSecurityHeaders(response) {
7777
});
7878
}
7979

80-
function broadcastOffline() {
80+
// Tracks whether any network-first request has fallen back to cache, so that we can broadcast ONLINE when the network becomes reachable again
81+
let servingFromCache = false;
82+
83+
self.addEventListener("online", () => {
84+
servingFromCache = false;
85+
broadcast("ONLINE");
86+
});
87+
88+
// Send CHECK_ONLINE when offline. We probe the network directly here because SW-initiated fetches bypass the SW's own fetch handler, hitting the network without being served from cache
89+
self.addEventListener("message", (event) => {
90+
if (event.data?.type !== "CHECK_ONLINE") return;
91+
fetch("./manifest.json", { cache: "no-store" })
92+
.then(() => {
93+
servingFromCache = false;
94+
broadcast("ONLINE");
95+
})
96+
.catch(() => {});
97+
});
98+
99+
function broadcast(type) {
81100
self.clients
82101
.matchAll()
83-
.then((clients) => clients.forEach((c) => c.postMessage({ type: "OFFLINE" })));
102+
.then((clients) => clients.forEach((c) => c.postMessage({ type })));
84103
}
85104

86-
// Network-first: try the network, update the cache, fall back to cache
105+
// Network-first try the network, update the cache, fall back to cache
87106
async function networkFirst(request, cacheName) {
88107
const cache = await caches.open(cacheName);
89108
try {
90109
const networkResponse = await fetch(request);
91110
if (networkResponse.ok) {
92111
cache.put(request, addSecurityHeaders(networkResponse.clone()));
112+
if (servingFromCache) {
113+
servingFromCache = false;
114+
broadcast("ONLINE");
115+
}
93116
}
94117
return addSecurityHeaders(networkResponse);
95118
} catch {
96119
const cached = await cache.match(request);
97120
if (cached) {
98-
broadcastOffline();
121+
servingFromCache = true;
122+
broadcast("OFFLINE");
99123
return addSecurityHeaders(cached);
100124
}
101125
return Response.error();
@@ -139,7 +163,10 @@ self.addEventListener("fetch", (event) => {
139163
}
140164

141165
// Translation files get their own cache so they can be evicted independently of the app shell
142-
if (url.origin === self.location.origin && url.pathname.includes("/translations/")) {
166+
if (
167+
url.origin === self.location.origin &&
168+
url.pathname.includes("/translations/")
169+
) {
143170
event.respondWith(networkFirst(event.request, TRANSLATIONS_CACHE));
144171
return;
145172
}

src/hooks/useIsOnline.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ const useIsOnline = () => {
99
window.addEventListener("online", handleOnline);
1010
window.addEventListener("offline", handleOffline);
1111

12-
// The service worker broadcasts OFFLINE whenever a network-first fetch falls back to cache, which reliably catches the case where navigator.onLine hasn't settled yet after a page reload when offline
13-
// This ensures that we can show "offline" state / UI immediately on page load when offline
12+
// The service worker broadcasts OFFLINE when a network-first fetch falls back to cache (catching cases where navigator.onLine hasn't settled on page reload), and ONLINE when the network becomes reachable again after a cache fallback period
1413
const handleSWMessage = ({ data }) => {
1514
if (data?.type === "OFFLINE") setIsOnline(false);
15+
if (data?.type === "ONLINE") setIsOnline(true);
1616
};
1717
if ("serviceWorker" in navigator) {
1818
navigator.serviceWorker.addEventListener("message", handleSWMessage);
@@ -27,6 +27,16 @@ const useIsOnline = () => {
2727
};
2828
}, []);
2929

30+
// While offline, poll the SW every 3s to check if the network has returned, broadcast ONLINE if successful
31+
useEffect(() => {
32+
if (isOnline || !("serviceWorker" in navigator)) return;
33+
const interval = setInterval(async () => {
34+
const reg = await navigator.serviceWorker.ready;
35+
reg.active?.postMessage({ type: "CHECK_ONLINE" });
36+
}, 3000);
37+
return () => clearInterval(interval);
38+
}, [isOnline]);
39+
3040
return isOnline;
3141
};
3242

src/hooks/useIsOnline.test.js

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,21 @@ const dispatchWindowEvent = (type) => {
99

1010
describe("useIsOnline", () => {
1111
let swEventTarget;
12+
let mockPostMessage;
1213

1314
beforeEach(() => {
1415
Object.defineProperty(navigator, "onLine", {
1516
configurable: true,
1617
get: () => true,
1718
});
1819

19-
// jsdom doesn't implement navigator.serviceWorker so provide a minimal stub
20+
mockPostMessage = jest.fn();
2021
swEventTarget = new EventTarget();
22+
swEventTarget.ready = Promise.resolve({
23+
active: { postMessage: mockPostMessage },
24+
});
25+
26+
// jsdom doesn't implement navigator.serviceWorker so provide a minimal stub
2127
Object.defineProperty(navigator, "serviceWorker", {
2228
configurable: true,
2329
value: swEventTarget,
@@ -46,32 +52,93 @@ describe("useIsOnline", () => {
4652

4753
test("updates to false when the offline window event fires", () => {
4854
const { result } = renderHook(() => useIsOnline());
49-
expect(result.current).toBe(true);
5055
dispatchWindowEvent("offline");
5156
expect(result.current).toBe(false);
5257
});
5358

5459
test("updates to true when the online window event fires", () => {
55-
Object.defineProperty(navigator, "onLine", {
56-
configurable: true,
57-
get: () => false,
58-
});
5960
const { result } = renderHook(() => useIsOnline());
60-
expect(result.current).toBe(false);
61+
dispatchWindowEvent("offline");
6162
dispatchWindowEvent("online");
6263
expect(result.current).toBe(true);
6364
});
6465

6566
test("updates to false when the service worker broadcasts OFFLINE", () => {
6667
const { result } = renderHook(() => useIsOnline());
67-
expect(result.current).toBe(true);
6868
dispatchSWMessage({ type: "OFFLINE" });
6969
expect(result.current).toBe(false);
7070
});
7171

72+
test("updates to true when the service worker broadcasts ONLINE", () => {
73+
const { result } = renderHook(() => useIsOnline());
74+
dispatchSWMessage({ type: "OFFLINE" });
75+
dispatchSWMessage({ type: "ONLINE" });
76+
expect(result.current).toBe(true);
77+
});
78+
7279
test("ignores service worker messages with a different type", () => {
7380
const { result } = renderHook(() => useIsOnline());
7481
dispatchSWMessage({ type: "OTHER" });
7582
expect(result.current).toBe(true);
7683
});
84+
85+
describe("connectivity polling", () => {
86+
beforeEach(() => {
87+
jest.useFakeTimers();
88+
});
89+
90+
afterEach(() => {
91+
jest.useRealTimers();
92+
});
93+
94+
test("does not poll while online", async () => {
95+
renderHook(() => useIsOnline());
96+
await act(async () => {
97+
jest.advanceTimersByTime(6000);
98+
});
99+
expect(mockPostMessage).not.toHaveBeenCalled();
100+
});
101+
102+
test("sends CHECK_ONLINE to the service worker every 3 seconds while offline", async () => {
103+
const { result } = renderHook(() => useIsOnline());
104+
dispatchSWMessage({ type: "OFFLINE" });
105+
expect(result.current).toBe(false);
106+
107+
await act(async () => {
108+
jest.advanceTimersByTime(3000);
109+
});
110+
expect(mockPostMessage).toHaveBeenCalledTimes(1);
111+
expect(mockPostMessage).toHaveBeenCalledWith({ type: "CHECK_ONLINE" });
112+
113+
await act(async () => {
114+
jest.advanceTimersByTime(3000);
115+
});
116+
expect(mockPostMessage).toHaveBeenCalledTimes(2);
117+
});
118+
119+
test("stops polling when the service worker broadcasts ONLINE", async () => {
120+
const { result } = renderHook(() => useIsOnline());
121+
dispatchSWMessage({ type: "OFFLINE" });
122+
dispatchSWMessage({ type: "ONLINE" });
123+
expect(result.current).toBe(true);
124+
125+
await act(async () => {
126+
jest.advanceTimersByTime(6000);
127+
});
128+
expect(mockPostMessage).not.toHaveBeenCalled();
129+
});
130+
131+
test("stops polling on unmount", async () => {
132+
const { result, unmount } = renderHook(() => useIsOnline());
133+
dispatchSWMessage({ type: "OFFLINE" });
134+
expect(result.current).toBe(false);
135+
136+
unmount();
137+
138+
await act(async () => {
139+
jest.advanceTimersByTime(6000);
140+
});
141+
expect(mockPostMessage).not.toHaveBeenCalled();
142+
});
143+
});
77144
});

0 commit comments

Comments
 (0)