✈️(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:
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ describe('useSWRegister', () => {
|
||||
value: {
|
||||
register: registerSpy,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './hooks/useOffline';
|
||||
export * from './hooks/useSWRegister';
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user