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 } />