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 aebf2bf10..b5f97f587 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 f9fdfae02..198fc91ff 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 000000000..183634e58 --- /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 000000000..a2920ce2a --- /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 1698907cd..cf168d8da 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 fe4ba15e4..14ec6d88a 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 000000000..3dd499422 --- /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 da897cff5..5021862de 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 79604f23f..a05a7d1e2 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 63127f971..7a03f291d 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 000000000..455227e45 --- /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 b4bf7b961..947b27370 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 aa8f28614..b4db83f6d 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 683341c28..73e41facd 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 74f2d0391..295672436 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 } />