From 6377c8fccae5230fc5aee071e2c6b85a208615c6 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 7 May 2025 22:39:07 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=88=EF=B8=8F(frontend)=20allow=20editing?= =?UTF-8?q?=20when=20offline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../hooks/useIsCollaborativeEditable.tsx | 7 +- .../__tests__/ApiPlugin.test.tsx | 2 +- .../__tests__/OfflinePlugin.test.tsx | 65 +++++++++++++++++++ .../__tests__/useOffline.test.tsx | 63 ++++++++++++++++++ .../__tests__/useSWRegister.test.tsx | 1 + .../src/features/service-worker/conf.ts | 7 +- .../service-worker/hooks/useOffline.tsx | 38 +++++++++++ .../service-worker/hooks/useSWRegister.tsx | 15 ++++- .../src/features/service-worker/index.ts | 1 + .../service-worker/{ => plugins}/ApiPlugin.ts | 6 +- .../service-worker/plugins/OfflinePlugin.ts | 36 ++++++++++ .../service-worker/service-worker-api.ts | 9 ++- .../features/service-worker/service-worker.ts | 19 +++++- src/frontend/apps/impress/src/pages/_app.tsx | 3 +- .../impress/src/pages/docs/[id]/index.tsx | 2 +- 15 files changed, 259 insertions(+), 15 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/service-worker/__tests__/OfflinePlugin.test.tsx create mode 100644 src/frontend/apps/impress/src/features/service-worker/__tests__/useOffline.test.tsx create mode 100644 src/frontend/apps/impress/src/features/service-worker/hooks/useOffline.tsx rename src/frontend/apps/impress/src/features/service-worker/{ => plugins}/ApiPlugin.ts (98%) create mode 100644 src/frontend/apps/impress/src/features/service-worker/plugins/OfflinePlugin.ts diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx index aebf2bf1..b5f97f58 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx @@ -1,5 +1,7 @@ import { useEffect, useState } from 'react'; +import { useIsOffline } from '@/features/service-worker'; + import { useProviderStore } from '../stores'; import { Doc, LinkReach } from '../types'; @@ -12,12 +14,13 @@ export const useIsCollaborativeEditable = (doc: Doc) => { const isShared = docIsPublic || docIsAuth || docHasMember; const [isEditable, setIsEditable] = useState(true); const [isLoading, setIsLoading] = useState(true); + const { isOffline } = useIsOffline(); /** * Connection can take a few seconds */ useEffect(() => { - const _isEditable = isConnected || !isShared; + const _isEditable = isConnected || !isShared || isOffline; setIsLoading(true); if (_isEditable) { @@ -32,7 +35,7 @@ export const useIsCollaborativeEditable = (doc: Doc) => { }, 5000); return () => clearTimeout(timer); - }, [isConnected, isShared]); + }, [isConnected, isOffline, isShared]); return { isEditable, diff --git a/src/frontend/apps/impress/src/features/service-worker/__tests__/ApiPlugin.test.tsx b/src/frontend/apps/impress/src/features/service-worker/__tests__/ApiPlugin.test.tsx index f9fdfae0..198fc91f 100644 --- a/src/frontend/apps/impress/src/features/service-worker/__tests__/ApiPlugin.test.tsx +++ b/src/frontend/apps/impress/src/features/service-worker/__tests__/ApiPlugin.test.tsx @@ -4,8 +4,8 @@ import '@testing-library/jest-dom'; -import { ApiPlugin } from '../ApiPlugin'; import { RequestSerializer } from '../RequestSerializer'; +import { ApiPlugin } from '../plugins/ApiPlugin'; const mockedGet = jest.fn().mockResolvedValue({}); const mockedGetAllKeys = jest.fn().mockResolvedValue([]); diff --git a/src/frontend/apps/impress/src/features/service-worker/__tests__/OfflinePlugin.test.tsx b/src/frontend/apps/impress/src/features/service-worker/__tests__/OfflinePlugin.test.tsx new file mode 100644 index 00000000..183634e5 --- /dev/null +++ b/src/frontend/apps/impress/src/features/service-worker/__tests__/OfflinePlugin.test.tsx @@ -0,0 +1,65 @@ +/** + * @jest-environment node + */ + +import '@testing-library/jest-dom'; + +import { MESSAGE_TYPE } from '../conf'; +import { OfflinePlugin } from '../plugins/OfflinePlugin'; + +const mockServiceWorkerScope = { + clients: { + matchAll: jest.fn().mockResolvedValue([]), + }, +} as unknown as ServiceWorkerGlobalScope; + +(global as any).self = { + ...global, + clients: mockServiceWorkerScope.clients, +} as unknown as ServiceWorkerGlobalScope; + +describe('OfflinePlugin', () => { + afterEach(() => jest.clearAllMocks()); + + it(`calls fetchDidSucceed`, async () => { + const apiPlugin = new OfflinePlugin(); + const postMessageSpy = jest.spyOn(apiPlugin, 'postMessage'); + + await apiPlugin.fetchDidSucceed?.({ + response: new Response(), + } as any); + + expect(postMessageSpy).toHaveBeenCalledWith(false, 'fetchDidSucceed'); + }); + + it(`calls fetchDidFail`, async () => { + const apiPlugin = new OfflinePlugin(); + const postMessageSpy = jest.spyOn(apiPlugin, 'postMessage'); + + await apiPlugin.fetchDidFail?.({} as any); + + expect(postMessageSpy).toHaveBeenCalledWith(true, 'fetchDidFail'); + }); + + it(`calls postMessage`, async () => { + const apiPlugin = new OfflinePlugin(); + const mockClients = [ + { postMessage: jest.fn() }, + { postMessage: jest.fn() }, + ]; + + mockServiceWorkerScope.clients.matchAll = jest + .fn() + .mockResolvedValue(mockClients); + + await apiPlugin.postMessage(false, 'testMessage'); + + for (const client of mockClients) { + expect(client.postMessage).toHaveBeenCalledWith({ + type: MESSAGE_TYPE.OFFLINE, + value: false, + message: 'testMessage', + }); + } + }); +}); diff --git a/src/frontend/apps/impress/src/features/service-worker/__tests__/useOffline.test.tsx b/src/frontend/apps/impress/src/features/service-worker/__tests__/useOffline.test.tsx new file mode 100644 index 00000000..a2920ce2 --- /dev/null +++ b/src/frontend/apps/impress/src/features/service-worker/__tests__/useOffline.test.tsx @@ -0,0 +1,63 @@ +import '@testing-library/jest-dom'; +import { act, renderHook } from '@testing-library/react'; + +import { MESSAGE_TYPE } from '../conf'; +import { useIsOffline, useOffline } from '../hooks/useOffline'; + +const mockAddEventListener = jest.fn(); +const mockRemoveEventListener = jest.fn(); +Object.defineProperty(navigator, 'serviceWorker', { + value: { + addEventListener: mockAddEventListener, + removeEventListener: mockRemoveEventListener, + }, + writable: true, +}); + +describe('useOffline', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should set isOffline to true when receiving an offline message', () => { + useIsOffline.setState({ isOffline: false }); + + const { result } = renderHook(() => useIsOffline()); + renderHook(() => useOffline()); + + act(() => { + const messageEvent = { + data: { + type: MESSAGE_TYPE.OFFLINE, + value: true, + message: 'Offline', + }, + }; + + mockAddEventListener.mock.calls[0][1](messageEvent); + }); + + expect(result.current.isOffline).toBe(true); + }); + + it('should set isOffline to false when receiving an online message', () => { + useIsOffline.setState({ isOffline: false }); + + const { result } = renderHook(() => useIsOffline()); + renderHook(() => useOffline()); + + act(() => { + const messageEvent = { + data: { + type: MESSAGE_TYPE.OFFLINE, + value: false, + message: 'Online', + }, + }; + + mockAddEventListener.mock.calls[0][1](messageEvent); + }); + + expect(result.current.isOffline).toBe(false); + }); +}); diff --git a/src/frontend/apps/impress/src/features/service-worker/__tests__/useSWRegister.test.tsx b/src/frontend/apps/impress/src/features/service-worker/__tests__/useSWRegister.test.tsx index 1698907c..cf168d8d 100644 --- a/src/frontend/apps/impress/src/features/service-worker/__tests__/useSWRegister.test.tsx +++ b/src/frontend/apps/impress/src/features/service-worker/__tests__/useSWRegister.test.tsx @@ -26,6 +26,7 @@ describe('useSWRegister', () => { value: { register: registerSpy, addEventListener: jest.fn(), + removeEventListener: jest.fn(), }, writable: true, }); diff --git a/src/frontend/apps/impress/src/features/service-worker/conf.ts b/src/frontend/apps/impress/src/features/service-worker/conf.ts index fe4ba15e..14ec6d88 100644 --- a/src/frontend/apps/impress/src/features/service-worker/conf.ts +++ b/src/frontend/apps/impress/src/features/service-worker/conf.ts @@ -3,14 +3,15 @@ import pkg from '@/../package.json'; export const SW_DEV_URL = [ 'http://localhost:3000', 'https://impress.127.0.0.1.nip.io', - 'https://impress-staging.beta.numerique.gouv.fr', ]; export const SW_DEV_API = 'http://localhost:8071'; - export const SW_VERSION = `v-${process.env.NEXT_PUBLIC_BUILD_ID}`; - export const DAYS_EXP = 5; export const getCacheNameVersion = (cacheName: string) => `${pkg.name}-${cacheName}-${SW_VERSION}`; + +export const MESSAGE_TYPE = { + OFFLINE: 'OFFLINE', +}; diff --git a/src/frontend/apps/impress/src/features/service-worker/hooks/useOffline.tsx b/src/frontend/apps/impress/src/features/service-worker/hooks/useOffline.tsx new file mode 100644 index 00000000..3dd49942 --- /dev/null +++ b/src/frontend/apps/impress/src/features/service-worker/hooks/useOffline.tsx @@ -0,0 +1,38 @@ +import { useEffect } from 'react'; +import { create } from 'zustand'; + +import { MESSAGE_TYPE } from '../conf'; + +interface OfflineMessageData { + type: string; + value: boolean; + message: string; +} + +interface IsOfflineState { + isOffline: boolean; + setIsOffline: (value: boolean) => void; +} + +export const useIsOffline = create((set) => ({ + isOffline: typeof navigator !== 'undefined' && !navigator.onLine, + setIsOffline: (value: boolean) => set({ isOffline: value }), +})); + +export const useOffline = () => { + const { setIsOffline } = useIsOffline(); + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.data?.type === MESSAGE_TYPE.OFFLINE) { + setIsOffline(event.data.value); + } + }; + + navigator.serviceWorker?.addEventListener('message', handleMessage); + + return () => { + navigator.serviceWorker?.removeEventListener('message', handleMessage); + }; + }, [setIsOffline]); +}; diff --git a/src/frontend/apps/impress/src/features/service-worker/hooks/useSWRegister.tsx b/src/frontend/apps/impress/src/features/service-worker/hooks/useSWRegister.tsx index da897cff..5021862d 100644 --- a/src/frontend/apps/impress/src/features/service-worker/hooks/useSWRegister.tsx +++ b/src/frontend/apps/impress/src/features/service-worker/hooks/useSWRegister.tsx @@ -30,11 +30,22 @@ export const useSWRegister = () => { }); const currentController = navigator.serviceWorker.controller; - navigator.serviceWorker.addEventListener('controllerchange', () => { + const onControllerChange = () => { if (currentController) { window.location.reload(); } - }); + }; + navigator.serviceWorker.addEventListener( + 'controllerchange', + onControllerChange, + ); + + return () => { + navigator.serviceWorker.removeEventListener( + 'controllerchange', + onControllerChange, + ); + }; } }, []); }; diff --git a/src/frontend/apps/impress/src/features/service-worker/index.ts b/src/frontend/apps/impress/src/features/service-worker/index.ts index 79604f23..a05a7d1e 100644 --- a/src/frontend/apps/impress/src/features/service-worker/index.ts +++ b/src/frontend/apps/impress/src/features/service-worker/index.ts @@ -1 +1,2 @@ +export * from './hooks/useOffline'; export * from './hooks/useSWRegister'; diff --git a/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts similarity index 98% rename from src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts rename to src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts index 63127f97..7a03f291 100644 --- a/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts @@ -3,9 +3,9 @@ import { WorkboxPlugin } from 'workbox-core'; import { Doc, DocsResponse } from '@/docs/doc-management'; import { LinkReach, LinkRole } from '@/docs/doc-management/types'; -import { DBRequest, DocsDB } from './DocsDB'; -import { RequestSerializer } from './RequestSerializer'; -import { SyncManager } from './SyncManager'; +import { DBRequest, DocsDB } from '../DocsDB'; +import { RequestSerializer } from '../RequestSerializer'; +import { SyncManager } from '../SyncManager'; interface OptionsReadonly { tableName: 'doc-list' | 'doc-item'; diff --git a/src/frontend/apps/impress/src/features/service-worker/plugins/OfflinePlugin.ts b/src/frontend/apps/impress/src/features/service-worker/plugins/OfflinePlugin.ts new file mode 100644 index 00000000..455227e4 --- /dev/null +++ b/src/frontend/apps/impress/src/features/service-worker/plugins/OfflinePlugin.ts @@ -0,0 +1,36 @@ +import { WorkboxPlugin } from 'workbox-core'; + +import { MESSAGE_TYPE } from '../conf'; + +declare const self: ServiceWorkerGlobalScope; + +export class OfflinePlugin implements WorkboxPlugin { + constructor() {} + + postMessage = async (value: boolean, message: string) => { + const allClients = await self.clients.matchAll({ + includeUncontrolled: true, + }); + + for (const client of allClients) { + client.postMessage({ + type: MESSAGE_TYPE.OFFLINE, + value, + message, + }); + } + }; + + /** + * Means that the fetch failed (500 is not failed), so often it is a network error. + */ + fetchDidFail: WorkboxPlugin['fetchDidFail'] = async () => { + void this.postMessage(true, 'fetchDidFail'); + return Promise.resolve(); + }; + + fetchDidSucceed: WorkboxPlugin['fetchDidSucceed'] = async ({ response }) => { + void this.postMessage(false, 'fetchDidSucceed'); + return Promise.resolve(response); + }; +} diff --git a/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts b/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts index b4bf7b96..947b2737 100644 --- a/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts +++ b/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts @@ -3,10 +3,11 @@ import { ExpirationPlugin } from 'workbox-expiration'; import { registerRoute } from 'workbox-routing'; import { NetworkFirst, NetworkOnly } from 'workbox-strategies'; -import { ApiPlugin } from './ApiPlugin'; import { DocsDB } from './DocsDB'; import { SyncManager } from './SyncManager'; import { DAYS_EXP, SW_DEV_API, getCacheNameVersion } from './conf'; +import { ApiPlugin } from './plugins/ApiPlugin'; +import { OfflinePlugin } from './plugins/OfflinePlugin'; declare const self: ServiceWorkerGlobalScope; @@ -37,6 +38,7 @@ registerRoute( type: 'list', syncManager, }), + new OfflinePlugin(), ], }), 'GET', @@ -52,6 +54,7 @@ registerRoute( type: 'item', syncManager, }), + new OfflinePlugin(), ], }), 'GET', @@ -66,6 +69,7 @@ registerRoute( type: 'update', syncManager, }), + new OfflinePlugin(), ], }), 'PATCH', @@ -79,6 +83,7 @@ registerRoute( type: 'create', syncManager, }), + new OfflinePlugin(), ], }), 'POST', @@ -93,6 +98,7 @@ registerRoute( type: 'delete', syncManager, }), + new OfflinePlugin(), ], }), 'DELETE', @@ -111,6 +117,7 @@ registerRoute( type: 'synch', syncManager, }), + new OfflinePlugin(), ], }), 'GET', diff --git a/src/frontend/apps/impress/src/features/service-worker/service-worker.ts b/src/frontend/apps/impress/src/features/service-worker/service-worker.ts index aa8f2861..b4db83f6 100644 --- a/src/frontend/apps/impress/src/features/service-worker/service-worker.ts +++ b/src/frontend/apps/impress/src/features/service-worker/service-worker.ts @@ -19,8 +19,9 @@ import { } from 'workbox-strategies'; // eslint-disable-next-line import/order -import { ApiPlugin } from './ApiPlugin'; import { DAYS_EXP, SW_DEV_URL, SW_VERSION, getCacheNameVersion } from './conf'; +import { ApiPlugin } from './plugins/ApiPlugin'; +import { OfflinePlugin } from './plugins/OfflinePlugin'; import { isApiUrl } from './service-worker-api'; // eslint-disable-next-line import/order @@ -154,6 +155,7 @@ registerRoute( plugins: [ new CacheableResponsePlugin({ statuses: [0, 200] }), new ExpirationPlugin({ maxAgeSeconds: 24 * 60 * 60 * DAYS_EXP }), + new OfflinePlugin(), ], }), ); @@ -170,6 +172,7 @@ registerRoute( new ExpirationPlugin({ maxAgeSeconds: 24 * 60 * 60 * DAYS_EXP, }), + new OfflinePlugin(), ], }), 'GET', @@ -236,6 +239,20 @@ registerRoute( }), ); +/** + * External urls post cache strategy + * It is interesting to intercept the request + * to have a fine grain control about if the user is + * online or offline + */ +registerRoute( + ({ url }) => !url.href.includes(self.location.origin) && !isApiUrl(url.href), + new NetworkOnly({ + plugins: [new OfflinePlugin()], + }), + 'POST', +); + /** * Cache all other files */ diff --git a/src/frontend/apps/impress/src/pages/_app.tsx b/src/frontend/apps/impress/src/pages/_app.tsx index 683341c2..73e41fac 100644 --- a/src/frontend/apps/impress/src/pages/_app.tsx +++ b/src/frontend/apps/impress/src/pages/_app.tsx @@ -3,7 +3,7 @@ import Head from 'next/head'; import { useTranslation } from 'react-i18next'; import { AppProvider } from '@/core/'; -import { useSWRegister } from '@/features/service-worker/'; +import { useOffline, useSWRegister } from '@/features/service-worker/'; import '@/i18n/initI18n'; import { NextPageWithLayout } from '@/types/next'; @@ -15,6 +15,7 @@ type AppPropsWithLayout = AppProps & { export default function App({ Component, pageProps }: AppPropsWithLayout) { useSWRegister(); + useOffline(); const getLayout = Component.getLayout ?? ((page) => page); const { t } = useTranslation(); diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index 74f2d039..29567243 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -126,7 +126,7 @@ const DocPage = ({ id }: DocProps) => { causes={error.cause} icon={ error.status === 502 ? ( - + ) : undefined } />