Skip to content

Commit 4b36e30

Browse files
committed
feat: improve offline support with service worker and related tests
1 parent 2bd5a36 commit 4b36e30

8 files changed

Lines changed: 334 additions & 38 deletions

File tree

package-lock.json

Lines changed: 65 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
"build": "stencil build",
66
"start": "stencil build --dev --watch --serve",
77
"test": "npm run build && wdio run wdio.conf.ts",
8-
"test:dev": "wdio run wdio.conf.ts --baseUrl=http://localhost:3333"
8+
"test:dev": "wdio run wdio.conf.ts --baseUrl=http://localhost:3333",
9+
"build:offline": "OFFLINE_SUPPORT=true stencil build",
10+
"test:offline": "npm run build:offline && TEST_OFFLINE=true wdio run wdio.conf.ts"
911
},
1012
"devDependencies": {
1113
"@ionic/core": "^8.6.0",
@@ -21,6 +23,7 @@
2123
"@wdio/mocha-framework": "^9.24.0",
2224
"@wdio/spec-reporter": "^9.24.0",
2325
"@wdio/static-server-service": "^9.24.0",
26+
"puppeteer-core": "^24.40.0",
2427
"swiper": "^11.2.10",
2528
"typescript": "^5.9.3",
2629
"workbox-build": "^7.3.0"

src/services/index.ts

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,69 @@
11
import { Env } from '@stencil/core';
2-
import { ServiceFacade } from "@smartcompanion/services";
2+
import { ServiceFacade } from '@smartcompanion/services';
33

4-
const isServiceWorkerActive = () => new Promise<void>(resolve => {
5-
navigator.serviceWorker.getRegistration().then(registration => {
6-
if (registration && registration.active) {
4+
const isServiceWorkerReady = () =>
5+
new Promise<void>((resolve) => {
6+
if (!('serviceWorker' in navigator)) {
77
resolve();
8-
} else if (!registration) {
9-
console.warn("No service worker registration found.");
10-
resolve();
11-
} else {
12-
setTimeout(() => {
13-
isServiceWorkerActive().then(resolve);
14-
}, 500);
8+
return;
159
}
10+
11+
navigator.serviceWorker.getRegistration().then((registration) => {
12+
if (!registration) {
13+
console.warn('No service worker registration found.');
14+
resolve();
15+
return;
16+
}
17+
18+
if (navigator.serviceWorker.controller) {
19+
resolve();
20+
return;
21+
}
22+
23+
// Wait for the SW to take control (fires after activate + clients.claim())
24+
navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true });
25+
26+
// Fallback timeout to avoid hanging indefinitely
27+
setTimeout(() => {
28+
console.warn('Service worker controller timed out, proceeding anyway.');
29+
resolve();
30+
}, 10000);
31+
});
1632
});
17-
});
1833

1934
const serviceFacade = new ServiceFacade();
2035

2136
serviceFacade.registerDefaultServices();
2237
serviceFacade.registerCollectibleAudioPlayerService(Env.TITLE);
2338

24-
25-
if (Env.OFFLINE_SUPPORT === "enabled") {
39+
if (Env.OFFLINE_SUPPORT === 'enabled') {
2640
serviceFacade.registerOfflineLoadService(
27-
() => fetch(Env.DATA_URL, {
28-
cache: "no-store",
29-
}).then(response => response.json()),
30-
(url: string) => isServiceWorkerActive().then(() => fetch(url).then(response => response.text())),
41+
() =>
42+
fetch(Env.DATA_URL)
43+
.then((response) => {
44+
if (!response.ok) throw new Error(`Data fetch failed: ${response.status}`);
45+
return response.json();
46+
})
47+
.catch((error) => {
48+
console.error('Failed to load data:', error);
49+
throw error;
50+
}),
51+
(url: string) =>
52+
isServiceWorkerReady().then(() =>
53+
fetch(url).then((response) => {
54+
if (!response.ok) throw new Error(`Asset fetch failed: ${response.status}`);
55+
return response.text();
56+
}),
57+
),
3158
(_: string) => Promise.resolve(),
3259
(_1: string, _2: string) => Promise.resolve(),
3360
() => Promise.resolve([]),
34-
)
61+
);
3562
} else {
36-
serviceFacade.registerOnlineLoadService(() =>
63+
serviceFacade.registerOnlineLoadService(() =>
3764
fetch(Env.DATA_URL, {
38-
cache: "no-store",
39-
}).then(response => response.json())
65+
cache: 'no-store',
66+
}).then((response) => response.json()),
4067
);
4168
}
4269

src/sw.js

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
1-
importScripts('workbox-v7.3.0/workbox-sw.js');
1+
importScripts('workbox-v7.4.0/workbox-sw.js');
22

3-
workbox.routing.registerRoute(
4-
// it is assumed that cachable assets are in the 'assets' directory
3+
const { precaching, routing, strategies } = workbox;
4+
5+
// Clean up old precache versions on update
6+
precaching.cleanupOutdatedCaches();
7+
8+
// Precache and route the app shell (JS, CSS, HTML, etc.)
9+
precaching.precacheAndRoute(self.__WB_MANIFEST);
10+
11+
// Cache the external data JSON with NetworkFirst strategy
12+
// Fresh data when online, falls back to cache when offline
13+
routing.registerRoute(
14+
({ url }) => url.pathname.endsWith('/data.json'),
15+
new strategies.NetworkFirst({
16+
cacheName: 'data-cache',
17+
})
18+
);
19+
20+
// Cache assets directory with CacheFirst
21+
routing.registerRoute(
522
({ request }) => request.url.match(/assets\/.+\..{2,}/),
6-
new workbox.strategies.CacheFirst()
23+
new strategies.CacheFirst({
24+
cacheName: 'assets-cache',
25+
})
726
);
827

28+
// On activate, immediately claim all clients
929
self.addEventListener('activate', event => {
1030
event.waitUntil(self.clients.claim());
1131
});
12-
13-
self.workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);

stencil.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { sass } from '@stencil/sass';
33

44
const TITLE = "Animals";
55
const DATA_URL = "https://smartcompanion-app.github.io/data-format/animals/data.json";
6-
const OFFLINE_SUPPORT = false;
6+
const OFFLINE_SUPPORT = process.env.OFFLINE_SUPPORT === 'true' || false;
77

88
export const config: Config = {
99
globalStyle: 'src/global/app.scss',
@@ -35,7 +35,7 @@ export const config: Config = {
3535
'**/send-outline.svg',
3636
]
3737
} : null,
38-
baseUrl: 'https://myapp.local/',
38+
baseUrl: '/',
3939
},
4040
],
4141
plugins: [
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
class ServiceWorkerComponent {
2+
async waitForServiceWorkerActive(timeout = 15000): Promise<void> {
3+
await browser.waitUntil(
4+
async () => {
5+
const swState: string = await browser.execute(() => {
6+
return new Promise<string>((resolve) => {
7+
if (!('serviceWorker' in navigator)) {
8+
resolve('unsupported');
9+
return;
10+
}
11+
navigator.serviceWorker.getRegistration().then((reg) => {
12+
if (reg && reg.active) {
13+
resolve('activated');
14+
} else if (reg && reg.installing) {
15+
resolve('installing');
16+
} else if (reg && reg.waiting) {
17+
resolve('waiting');
18+
} else {
19+
resolve('none');
20+
}
21+
});
22+
});
23+
});
24+
return swState === 'activated';
25+
},
26+
{ timeout, timeoutMsg: 'Service worker did not activate in time' },
27+
);
28+
}
29+
30+
async isControllingPage(): Promise<boolean> {
31+
return browser.execute(() => {
32+
return navigator.serviceWorker?.controller !== null;
33+
});
34+
}
35+
36+
async goOffline(): Promise<void> {
37+
await browser.call(async () => {
38+
const puppeteer = await browser.getPuppeteer();
39+
const pages = await puppeteer.pages();
40+
const cdpSession = await pages[0].createCDPSession();
41+
await cdpSession.send('Network.emulateNetworkConditions', {
42+
offline: true,
43+
latency: 0,
44+
downloadThroughput: 0,
45+
uploadThroughput: 0,
46+
});
47+
});
48+
}
49+
50+
async goOnline(): Promise<void> {
51+
await browser.call(async () => {
52+
const puppeteer = await browser.getPuppeteer();
53+
const pages = await puppeteer.pages();
54+
const cdpSession = await pages[0].createCDPSession();
55+
await cdpSession.send('Network.emulateNetworkConditions', {
56+
offline: false,
57+
latency: 0,
58+
downloadThroughput: -1,
59+
uploadThroughput: -1,
60+
});
61+
});
62+
}
63+
64+
async getCacheNames(): Promise<string[]> {
65+
return browser.execute(async () => {
66+
const names = await caches.keys();
67+
return names;
68+
});
69+
}
70+
71+
async freshReload(): Promise<void> {
72+
await browser.execute(() => {
73+
localStorage.clear();
74+
sessionStorage.clear();
75+
window.location.hash = '/';
76+
});
77+
await browser.pause(500);
78+
await browser.execute(() => {
79+
window.location.reload();
80+
});
81+
}
82+
83+
async isCached(urlPattern: string): Promise<boolean> {
84+
return browser.execute(async (pattern: string) => {
85+
const names = await caches.keys();
86+
for (const name of names) {
87+
const cache = await caches.open(name);
88+
const keys = await cache.keys();
89+
for (const request of keys) {
90+
if (request.url.includes(pattern)) {
91+
return true;
92+
}
93+
}
94+
}
95+
return false;
96+
}, urlPattern);
97+
}
98+
}
99+
100+
export default new ServiceWorkerComponent();

0 commit comments

Comments
 (0)