✈️(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.
This commit is contained in:
Anthony LC
2025-05-07 22:39:07 +02:00
parent 3c8cacc048
commit 6377c8fcca
15 changed files with 259 additions and 15 deletions

View File

@@ -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,

View File

@@ -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([]);

View File

@@ -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',
});
}
});
});

View File

@@ -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);
});
});

View File

@@ -26,6 +26,7 @@ describe('useSWRegister', () => {
value: {
register: registerSpy,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
},
writable: true,
});

View File

@@ -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',
};

View File

@@ -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<IsOfflineState>((set) => ({
isOffline: typeof navigator !== 'undefined' && !navigator.onLine,
setIsOffline: (value: boolean) => set({ isOffline: value }),
}));
export const useOffline = () => {
const { setIsOffline } = useIsOffline();
useEffect(() => {
const handleMessage = (event: MessageEvent<OfflineMessageData>) => {
if (event.data?.type === MESSAGE_TYPE.OFFLINE) {
setIsOffline(event.data.value);
}
};
navigator.serviceWorker?.addEventListener('message', handleMessage);
return () => {
navigator.serviceWorker?.removeEventListener('message', handleMessage);
};
}, [setIsOffline]);
};

View File

@@ -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,
);
};
}
}, []);
};

View File

@@ -1 +1,2 @@
export * from './hooks/useOffline';
export * from './hooks/useSWRegister';

View File

@@ -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';

View File

@@ -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);
};
}

View File

@@ -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',

View File

@@ -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
*/

View File

@@ -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();

View File

@@ -126,7 +126,7 @@ const DocPage = ({ id }: DocProps) => {
causes={error.cause}
icon={
error.status === 502 ? (
<Icon iconName="wifi_off" $theme="danger" />
<Icon iconName="wifi_off" $theme="danger" $variation="600" />
) : undefined
}
/>