Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit eea69a3

Browse files
committedMay 19, 2025··
✈️(frontend) allow editing when offline
When the user is offline, we allow editing the document in the editor. Their is not a reliable way to know if the user is offline or online except by doing a network request and checking if an error is thrown or not. To do so, we created the OfflinePlugin inherited from the WorkboxPlugin. It will inform us if the user is offline or online. We then dispatch the information to our application thanks to the useOffline hook.
1 parent e1343cf commit eea69a3

File tree

15 files changed

+259
-15
lines changed

15 files changed

+259
-15
lines changed
 

‎src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useEffect, useState } from 'react';
22

3+
import { useIsOffline } from '@/features/service-worker';
4+
35
import { useProviderStore } from '../stores';
46
import { Doc, LinkReach } from '../types';
57

@@ -12,12 +14,13 @@ export const useIsCollaborativeEditable = (doc: Doc) => {
1214
const isShared = docIsPublic || docIsAuth || docHasMember;
1315
const [isEditable, setIsEditable] = useState(true);
1416
const [isLoading, setIsLoading] = useState(true);
17+
const { isOffline } = useIsOffline();
1518

1619
/**
1720
* Connection can take a few seconds
1821
*/
1922
useEffect(() => {
20-
const _isEditable = isConnected || !isShared;
23+
const _isEditable = isConnected || !isShared || isOffline;
2124
setIsLoading(true);
2225

2326
if (_isEditable) {
@@ -32,7 +35,7 @@ export const useIsCollaborativeEditable = (doc: Doc) => {
3235
}, 5000);
3336

3437
return () => clearTimeout(timer);
35-
}, [isConnected, isShared]);
38+
}, [isConnected, isOffline, isShared]);
3639

3740
return {
3841
isEditable,

‎src/frontend/apps/impress/src/features/service-worker/__tests__/ApiPlugin.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
import '@testing-library/jest-dom';
66

7-
import { ApiPlugin } from '../ApiPlugin';
87
import { RequestSerializer } from '../RequestSerializer';
8+
import { ApiPlugin } from '../plugins/ApiPlugin';
99

1010
const mockedGet = jest.fn().mockResolvedValue({});
1111
const mockedGetAllKeys = jest.fn().mockResolvedValue([]);
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
5+
import '@testing-library/jest-dom';
6+
7+
import { MESSAGE_TYPE } from '../conf';
8+
import { OfflinePlugin } from '../plugins/OfflinePlugin';
9+
10+
const mockServiceWorkerScope = {
11+
clients: {
12+
matchAll: jest.fn().mockResolvedValue([]),
13+
},
14+
} as unknown as ServiceWorkerGlobalScope;
15+
16+
(global as any).self = {
17+
...global,
18+
clients: mockServiceWorkerScope.clients,
19+
} as unknown as ServiceWorkerGlobalScope;
20+
21+
describe('OfflinePlugin', () => {
22+
afterEach(() => jest.clearAllMocks());
23+
24+
it(`calls fetchDidSucceed`, async () => {
25+
const apiPlugin = new OfflinePlugin();
26+
const postMessageSpy = jest.spyOn(apiPlugin, 'postMessage');
27+
28+
await apiPlugin.fetchDidSucceed?.({
29+
response: new Response(),
30+
} as any);
31+
32+
expect(postMessageSpy).toHaveBeenCalledWith(false, 'fetchDidSucceed');
33+
});
34+
35+
it(`calls fetchDidFail`, async () => {
36+
const apiPlugin = new OfflinePlugin();
37+
const postMessageSpy = jest.spyOn(apiPlugin, 'postMessage');
38+
39+
await apiPlugin.fetchDidFail?.({} as any);
40+
41+
expect(postMessageSpy).toHaveBeenCalledWith(true, 'fetchDidFail');
42+
});
43+
44+
it(`calls postMessage`, async () => {
45+
const apiPlugin = new OfflinePlugin();
46+
const mockClients = [
47+
{ postMessage: jest.fn() },
48+
{ postMessage: jest.fn() },
49+
];
50+
51+
mockServiceWorkerScope.clients.matchAll = jest
52+
.fn()
53+
.mockResolvedValue(mockClients);
54+
55+
await apiPlugin.postMessage(false, 'testMessage');
56+
57+
for (const client of mockClients) {
58+
expect(client.postMessage).toHaveBeenCalledWith({
59+
type: MESSAGE_TYPE.OFFLINE,
60+
value: false,
61+
message: 'testMessage',
62+
});
63+
}
64+
});
65+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import '@testing-library/jest-dom';
2+
import { act, renderHook } from '@testing-library/react';
3+
4+
import { MESSAGE_TYPE } from '../conf';
5+
import { useIsOffline, useOffline } from '../hooks/useOffline';
6+
7+
const mockAddEventListener = jest.fn();
8+
const mockRemoveEventListener = jest.fn();
9+
Object.defineProperty(navigator, 'serviceWorker', {
10+
value: {
11+
addEventListener: mockAddEventListener,
12+
removeEventListener: mockRemoveEventListener,
13+
},
14+
writable: true,
15+
});
16+
17+
describe('useOffline', () => {
18+
beforeEach(() => {
19+
jest.clearAllMocks();
20+
});
21+
22+
it('should set isOffline to true when receiving an offline message', () => {
23+
useIsOffline.setState({ isOffline: false });
24+
25+
const { result } = renderHook(() => useIsOffline());
26+
renderHook(() => useOffline());
27+
28+
act(() => {
29+
const messageEvent = {
30+
data: {
31+
type: MESSAGE_TYPE.OFFLINE,
32+
value: true,
33+
message: 'Offline',
34+
},
35+
};
36+
37+
mockAddEventListener.mock.calls[0][1](messageEvent);
38+
});
39+
40+
expect(result.current.isOffline).toBe(true);
41+
});
42+
43+
it('should set isOffline to false when receiving an online message', () => {
44+
useIsOffline.setState({ isOffline: false });
45+
46+
const { result } = renderHook(() => useIsOffline());
47+
renderHook(() => useOffline());
48+
49+
act(() => {
50+
const messageEvent = {
51+
data: {
52+
type: MESSAGE_TYPE.OFFLINE,
53+
value: false,
54+
message: 'Online',
55+
},
56+
};
57+
58+
mockAddEventListener.mock.calls[0][1](messageEvent);
59+
});
60+
61+
expect(result.current.isOffline).toBe(false);
62+
});
63+
});

‎src/frontend/apps/impress/src/features/service-worker/__tests__/useSWRegister.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ describe('useSWRegister', () => {
2626
value: {
2727
register: registerSpy,
2828
addEventListener: jest.fn(),
29+
removeEventListener: jest.fn(),
2930
},
3031
writable: true,
3132
});

‎src/frontend/apps/impress/src/features/service-worker/conf.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ import pkg from '@/../package.json';
33
export const SW_DEV_URL = [
44
'http://localhost:3000',
55
'https://impress.127.0.0.1.nip.io',
6-
'https://impress-staging.beta.numerique.gouv.fr',
76
];
87

98
export const SW_DEV_API = 'http://localhost:8071';
10-
119
export const SW_VERSION = `v-${process.env.NEXT_PUBLIC_BUILD_ID}`;
12-
1310
export const DAYS_EXP = 5;
1411

1512
export const getCacheNameVersion = (cacheName: string) =>
1613
`${pkg.name}-${cacheName}-${SW_VERSION}`;
14+
15+
export const MESSAGE_TYPE = {
16+
OFFLINE: 'OFFLINE',
17+
};
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useEffect } from 'react';
2+
import { create } from 'zustand';
3+
4+
import { MESSAGE_TYPE } from '../conf';
5+
6+
interface OfflineMessageData {
7+
type: string;
8+
value: boolean;
9+
message: string;
10+
}
11+
12+
interface IsOfflineState {
13+
isOffline: boolean;
14+
setIsOffline: (value: boolean) => void;
15+
}
16+
17+
export const useIsOffline = create<IsOfflineState>((set) => ({
18+
isOffline: typeof navigator !== 'undefined' && !navigator.onLine,
19+
setIsOffline: (value: boolean) => set({ isOffline: value }),
20+
}));
21+
22+
export const useOffline = () => {
23+
const { setIsOffline } = useIsOffline();
24+
25+
useEffect(() => {
26+
const handleMessage = (event: MessageEvent<OfflineMessageData>) => {
27+
if (event.data?.type === MESSAGE_TYPE.OFFLINE) {
28+
setIsOffline(event.data.value);
29+
}
30+
};
31+
32+
navigator.serviceWorker?.addEventListener('message', handleMessage);
33+
34+
return () => {
35+
navigator.serviceWorker?.removeEventListener('message', handleMessage);
36+
};
37+
}, [setIsOffline]);
38+
};

‎src/frontend/apps/impress/src/features/service-worker/hooks/useSWRegister.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,22 @@ export const useSWRegister = () => {
3030
});
3131

3232
const currentController = navigator.serviceWorker.controller;
33-
navigator.serviceWorker.addEventListener('controllerchange', () => {
33+
const onControllerChange = () => {
3434
if (currentController) {
3535
window.location.reload();
3636
}
37-
});
37+
};
38+
navigator.serviceWorker.addEventListener(
39+
'controllerchange',
40+
onControllerChange,
41+
);
42+
43+
return () => {
44+
navigator.serviceWorker.removeEventListener(
45+
'controllerchange',
46+
onControllerChange,
47+
);
48+
};
3849
}
3950
}, []);
4051
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export * from './hooks/useOffline';
12
export * from './hooks/useSWRegister';

‎src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts renamed to ‎src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import { WorkboxPlugin } from 'workbox-core';
33
import { Doc, DocsResponse } from '@/docs/doc-management';
44
import { LinkReach, LinkRole } from '@/docs/doc-management/types';
55

6-
import { DBRequest, DocsDB } from './DocsDB';
7-
import { RequestSerializer } from './RequestSerializer';
8-
import { SyncManager } from './SyncManager';
6+
import { DBRequest, DocsDB } from '../DocsDB';
7+
import { RequestSerializer } from '../RequestSerializer';
8+
import { SyncManager } from '../SyncManager';
99

1010
interface OptionsReadonly {
1111
tableName: 'doc-list' | 'doc-item';
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { WorkboxPlugin } from 'workbox-core';
2+
3+
import { MESSAGE_TYPE } from '../conf';
4+
5+
declare const self: ServiceWorkerGlobalScope;
6+
7+
export class OfflinePlugin implements WorkboxPlugin {
8+
constructor() {}
9+
10+
postMessage = async (value: boolean, message: string) => {
11+
const allClients = await self.clients.matchAll({
12+
includeUncontrolled: true,
13+
});
14+
15+
for (const client of allClients) {
16+
client.postMessage({
17+
type: MESSAGE_TYPE.OFFLINE,
18+
value,
19+
message,
20+
});
21+
}
22+
};
23+
24+
/**
25+
* Means that the fetch failed (500 is not failed), so often it is a network error.
26+
*/
27+
fetchDidFail: WorkboxPlugin['fetchDidFail'] = async () => {
28+
void this.postMessage(true, 'fetchDidFail');
29+
return Promise.resolve();
30+
};
31+
32+
fetchDidSucceed: WorkboxPlugin['fetchDidSucceed'] = async ({ response }) => {
33+
void this.postMessage(false, 'fetchDidSucceed');
34+
return Promise.resolve(response);
35+
};
36+
}

‎src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import { ExpirationPlugin } from 'workbox-expiration';
33
import { registerRoute } from 'workbox-routing';
44
import { NetworkFirst, NetworkOnly } from 'workbox-strategies';
55

6-
import { ApiPlugin } from './ApiPlugin';
76
import { DocsDB } from './DocsDB';
87
import { SyncManager } from './SyncManager';
98
import { DAYS_EXP, SW_DEV_API, getCacheNameVersion } from './conf';
9+
import { ApiPlugin } from './plugins/ApiPlugin';
10+
import { OfflinePlugin } from './plugins/OfflinePlugin';
1011

1112
declare const self: ServiceWorkerGlobalScope;
1213

@@ -37,6 +38,7 @@ registerRoute(
3738
type: 'list',
3839
syncManager,
3940
}),
41+
new OfflinePlugin(),
4042
],
4143
}),
4244
'GET',
@@ -52,6 +54,7 @@ registerRoute(
5254
type: 'item',
5355
syncManager,
5456
}),
57+
new OfflinePlugin(),
5558
],
5659
}),
5760
'GET',
@@ -66,6 +69,7 @@ registerRoute(
6669
type: 'update',
6770
syncManager,
6871
}),
72+
new OfflinePlugin(),
6973
],
7074
}),
7175
'PATCH',
@@ -79,6 +83,7 @@ registerRoute(
7983
type: 'create',
8084
syncManager,
8185
}),
86+
new OfflinePlugin(),
8287
],
8388
}),
8489
'POST',
@@ -93,6 +98,7 @@ registerRoute(
9398
type: 'delete',
9499
syncManager,
95100
}),
101+
new OfflinePlugin(),
96102
],
97103
}),
98104
'DELETE',
@@ -111,6 +117,7 @@ registerRoute(
111117
type: 'synch',
112118
syncManager,
113119
}),
120+
new OfflinePlugin(),
114121
],
115122
}),
116123
'GET',

‎src/frontend/apps/impress/src/features/service-worker/service-worker.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ import {
1919
} from 'workbox-strategies';
2020

2121
// eslint-disable-next-line import/order
22-
import { ApiPlugin } from './ApiPlugin';
2322
import { DAYS_EXP, SW_DEV_URL, SW_VERSION, getCacheNameVersion } from './conf';
23+
import { ApiPlugin } from './plugins/ApiPlugin';
24+
import { OfflinePlugin } from './plugins/OfflinePlugin';
2425
import { isApiUrl } from './service-worker-api';
2526

2627
// eslint-disable-next-line import/order
@@ -150,6 +151,7 @@ registerRoute(
150151
plugins: [
151152
new CacheableResponsePlugin({ statuses: [0, 200] }),
152153
new ExpirationPlugin({ maxAgeSeconds: 24 * 60 * 60 * DAYS_EXP }),
154+
new OfflinePlugin(),
153155
],
154156
}),
155157
);
@@ -166,6 +168,7 @@ registerRoute(
166168
new ExpirationPlugin({
167169
maxAgeSeconds: 24 * 60 * 60 * DAYS_EXP,
168170
}),
171+
new OfflinePlugin(),
169172
],
170173
}),
171174
'GET',
@@ -232,6 +235,20 @@ registerRoute(
232235
}),
233236
);
234237

238+
/**
239+
* External urls post cache strategy
240+
* It is interesting to intercept the request
241+
* to have a fine grain control about if the user is
242+
* online or offline
243+
*/
244+
registerRoute(
245+
({ url }) => !url.href.includes(self.location.origin) && !isApiUrl(url.href),
246+
new NetworkOnly({
247+
plugins: [new OfflinePlugin()],
248+
}),
249+
'POST',
250+
);
251+
235252
/**
236253
* Cache all other files
237254
*/

‎src/frontend/apps/impress/src/pages/_app.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Head from 'next/head';
33
import { useTranslation } from 'react-i18next';
44

55
import { AppProvider } from '@/core/';
6-
import { useSWRegister } from '@/features/service-worker/';
6+
import { useOffline, useSWRegister } from '@/features/service-worker/';
77
import '@/i18n/initI18n';
88
import { NextPageWithLayout } from '@/types/next';
99

@@ -15,6 +15,7 @@ type AppPropsWithLayout = AppProps & {
1515

1616
export default function App({ Component, pageProps }: AppPropsWithLayout) {
1717
useSWRegister();
18+
useOffline();
1819
const getLayout = Component.getLayout ?? ((page) => page);
1920
const { t } = useTranslation();
2021

‎src/frontend/apps/impress/src/pages/docs/[id]/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ const DocPage = ({ id }: DocProps) => {
126126
causes={error.cause}
127127
icon={
128128
error.status === 502 ? (
129-
<Icon iconName="wifi_off" $theme="danger" />
129+
<Icon iconName="wifi_off" $theme="danger" $variation="600" />
130130
) : undefined
131131
}
132132
/>

0 commit comments

Comments
 (0)
Please sign in to comment.