Skip to content

Commit 30da3f3

Browse files
committed
✈️(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 861be2e commit 30da3f3

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

2225
if (_isEditable) {
2326
setIsEditable(true);
@@ -34,7 +37,7 @@ export const useIsCollaborativeEditable = (doc: Doc) => {
3437
);
3538

3639
return () => clearTimeout(timer);
37-
}, [isConnected, isLoading, isShared]);
40+
}, [isConnected, isLoading, isOffline, isShared]);
3841

3942
return {
4043
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',

0 commit comments

Comments
 (0)