✈️(SW) add offline support for content

We have added offline support for content.
When the content update fails, we save the new
content in the cache, and we will sync it later
with the SyncManager.
This commit is contained in:
Anthony LC 2026-04-14 12:30:53 +02:00
parent 6626138773
commit 2302643e3a
No known key found for this signature in database
10 changed files with 244 additions and 47 deletions

View file

@ -22,7 +22,7 @@ import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
import { useConfig } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, useProviderStore } from '@/docs/doc-management';
import { Doc } from '@/docs/doc-management';
import { avatarUrlFromName, useAuth } from '@/features/auth';
import { useAnalytics } from '@/libs/Analytics';
@ -92,13 +92,12 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { user } = useAuth();
const { setEditor } = useEditorStore();
const { themeTokens } = useCunninghamTheme();
const { isSynced: isConnectedToCollabServer } = useProviderStore();
const refEditorContainer = useRef<HTMLDivElement>(null);
const canSeeComment = doc.abilities.comment;
// Determine if comments should be visible in the UI
const showComments = canSeeComment;
useSaveDoc(doc.id, provider.document, isConnectedToCollabServer);
useSaveDoc(doc.id, provider.document);
const { i18n, t } = useTranslation();
const langLocalesBN =
!i18n.resolvedLanguage || !(i18n.resolvedLanguage in localesBN)

View file

@ -43,7 +43,7 @@ describe('useSaveDoc', () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
renderHook(() => useSaveDoc(docId, yDoc, true), {
renderHook(() => useSaveDoc(docId, yDoc), {
wrapper: AppWrapper,
});
@ -77,7 +77,7 @@ describe('useSaveDoc', () => {
},
);
renderHook(() => useSaveDoc(docId, yDoc, true), {
renderHook(() => useSaveDoc(docId, yDoc), {
wrapper: AppWrapper,
});
@ -116,7 +116,7 @@ describe('useSaveDoc', () => {
},
);
renderHook(() => useSaveDoc(docId, yDoc, true), {
renderHook(() => useSaveDoc(docId, yDoc), {
wrapper: AppWrapper,
});
@ -136,7 +136,7 @@ describe('useSaveDoc', () => {
const docId = 'test-doc-id';
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), {
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc), {
wrapper: AppWrapper,
});

View file

@ -2,22 +2,22 @@ import { useRouter } from 'next/router';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Y from 'yjs';
import { KEY_DOC_CONTENT } from '@/docs//doc-management/api/useDocContent';
import { useDocContentUpdate } from '@/docs/doc-management/api/useDocContentUpdate';
import { useProviderStore } from '@/docs/doc-management/stores/useProviderStore';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning/api/useDocVersions';
import { useIsOffline } from '@/features/service-worker';
import { toBase64 } from '@/utils/string';
import { isFirefox } from '@/utils/userAgent';
const SAVE_INTERVAL = 60000;
export const useSaveDoc = (
docId: string,
yDoc: Y.Doc,
isConnectedToCollabServer: boolean,
) => {
export const useSaveDoc = (docId: string, yDoc: Y.Doc) => {
const { isSynced: isConnectedToCollabServer } = useProviderStore();
const { isOffline } = useIsOffline();
const isSavingRef = useRef(false);
const { mutate: updateDocContent } = useDocContentUpdate({
listInvalidQueries: [KEY_LIST_DOC_VERSIONS, KEY_DOC_CONTENT],
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
isOptimistic: isOffline, // Enable optimistic updates when offline, to update the cache immediately
onSuccess: () => {
isSavingRef.current = false;
setIsLocalChange(false);

View file

@ -9,8 +9,9 @@ import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '../types';
import { KEY_CAN_EDIT } from './useDocCanEdit';
import { KEY_DOC_CONTENT } from './useDocContent';
interface UpdateDocContentParams {
export interface UpdateDocContentParams {
id: Doc['id'];
content: string; // Base64 encoded content
websocket?: boolean;
@ -42,6 +43,7 @@ type UseDocContentUpdate = UseMutationOptions<
APIError,
UpdateDocContentParams
> & {
isOptimistic?: boolean;
listInvalidQueries?: string[];
};
@ -50,7 +52,30 @@ export function useDocContentUpdate(queryConfig?: UseDocContentUpdate) {
return useMutation<void, APIError, UpdateDocContentParams>({
mutationFn: updateDocContent,
...queryConfig,
onMutate: (variables) => {
/**
* If optimistic, we update the content cache immediately with the new content
* It is useful when we are in offline mode because the onSuccess is not always triggered.
*/
if (queryConfig?.isOptimistic) {
queryClient.setQueryData(
[KEY_DOC_CONTENT, { id: variables.id }],
variables.content,
);
}
},
onSuccess: (data, variables, onMutateResult, context) => {
if (!queryConfig?.isOptimistic) {
/**
* If not optimistic, we need to update the content cache with the new content returned
* from the server
*/
queryClient.setQueryData(
[KEY_DOC_CONTENT, { id: variables.id }],
variables.content,
);
}
queryConfig?.listInvalidQueries?.forEach((queryKey) => {
void queryClient.resetQueries({
queryKey: [queryKey],

View file

@ -17,7 +17,6 @@ import { toBase64 } from '@/utils/string';
import { useProviderStore } from '../stores';
import { Doc } from '../types';
import { KEY_DOC_CONTENT } from './useDocContent';
import { useDocContentUpdate } from './useDocContentUpdate';
import { KEY_LIST_DOC } from './useDocs';
@ -64,7 +63,7 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
const { provider } = useProviderStore();
const { mutateAsync: updateDocContent } = useDocContentUpdate({
listInvalidQueries: [KEY_LIST_DOC_VERSIONS, KEY_DOC_CONTENT],
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
});
return useMutation<DuplicateDocResponse, APIError, DuplicateDocParams>({

View file

@ -7,6 +7,7 @@ import {
useDocContent,
} from '@/docs/doc-management/api/useDocContent';
import { useProviderStore } from '@/docs/doc-management/stores/useProviderStore';
import { useIsOffline } from '@/features/service-worker/hooks/useOffline';
import { useBroadcastStore } from '@/stores/useBroadcastStore';
import { KEY_DOC } from '../api';
@ -20,10 +21,12 @@ export const useCollaboration = (room: string) => {
provider,
createProvider,
destroyProvider,
setReady,
isReady,
hasLostConnection,
resetLostConnection,
} = useProviderStore();
const isOffline = useIsOffline((state) => state.isOffline);
const { data: docContent } = useDocContent(
{ id: room },
{
@ -32,6 +35,17 @@ export const useCollaboration = (room: string) => {
},
);
/**
* When offline, the WebSocket never connects so the provider would stay
* in a non-ready state for a long time. Immediately mark it as ready so
* the editor can render with the cached content.
*/
useEffect(() => {
if (isOffline && provider && !isReady) {
setReady(true);
}
}, [isOffline, isReady, provider, setReady]);
/**
* When the provider detects a lost connection, we invalidate the document query to trigger a refetch.
* Because it can be because the user has access to the document that are modified

View file

@ -12,6 +12,7 @@ export interface UseCollaborationStore {
initialDoc?: Base64,
) => HocuspocusProvider;
destroyProvider: () => void;
setReady: (value: boolean) => void;
provider: HocuspocusProvider | undefined;
isConnected: boolean;
isReady: boolean;
@ -161,5 +162,6 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
set(defaultValues);
},
setReady: (value: boolean) => set({ isReady: value }),
resetLostConnection: () => set({ hasLostConnection: false }),
}));

View file

@ -200,6 +200,7 @@ describe('ApiPlugin', () => {
[
{ type: 'list', tableName: 'doc-list' },
{ type: 'item', tableName: 'doc-item' },
{ type: 'content', tableName: 'doc-content' },
].forEach(({ type, tableName }) => {
it(`checks handlerDidError with type ${type}`, async () => {
const requestInit = {
@ -297,6 +298,72 @@ describe('ApiPlugin', () => {
expect(response?.status).toBe(200);
});
it(`checks handlerDidError with type content-update`, async () => {
const requestInit = {
request: {
url: 'http://test.jest/documents/123456/content/',
clone: () => mockedClone(),
headers: new Headers({
'Content-Type': 'application/json',
}),
arrayBuffer: () =>
RequestSerializer.objectToArrayBuffer({
content: 'test',
}),
json: () => ({
content: 'test',
}),
} as unknown as Request,
} as any;
const mockedClone = vi.fn().mockReturnValue(requestInit.request);
const mockedSync = vi.fn().mockResolvedValue({});
const apiPlugin = new ApiPlugin({
type: 'content-update',
syncManager: {
sync: () => mockedSync(),
} as any,
});
mockedGet.mockResolvedValue({
etag: '',
lastModified: '',
content: '',
});
await apiPlugin.requestWillFetch?.(requestInit);
await apiPlugin.fetchDidFail?.({} as any);
const response = await apiPlugin.handlerDidError?.(requestInit);
expect(mockedGet).toHaveBeenCalledWith(
'doc-content',
'http://test.jest/documents/123456/content/',
);
expect(mockedPut).toHaveBeenCalledWith(
'doc-mutation',
expect.objectContaining({
key: expect.any(String),
requestData: expect.objectContaining({
url: 'http://test.jest/documents/123456/content/',
headers: {
'content-type': 'application/json',
},
}),
}),
expect.any(String),
);
expect(mockedPut).toHaveBeenCalledWith(
'doc-content',
{ etag: '', lastModified: '', content: 'test' },
'http://test.jest/documents/123456/content/',
);
expect(mockedPut).toHaveBeenCalledTimes(2);
expect(mockedClose).toHaveBeenCalled();
expect(response?.status).toBe(204);
});
it(`checks handlerDidError with type delete`, async () => {
const requestInit = {
request: {
@ -317,6 +384,19 @@ describe('ApiPlugin', () => {
const mockedClone = vi.fn().mockReturnValue(requestInit.request);
const requestInitContent = {
request: {
url: 'http://test.jest/documents/123456/content/',
clone: () => mockedClone(),
headers: new Headers({
'Content-Type': 'text/plain',
}),
text: () => 'test-content',
} as unknown as Request,
} as any;
vi.fn().mockReturnValue(requestInitContent.request);
const mockedSync = vi.fn().mockResolvedValue({});
const apiPlugin = new ApiPlugin({
type: 'delete',
@ -346,6 +426,10 @@ describe('ApiPlugin', () => {
'doc-item',
'http://test.jest/documents/123456/',
);
expect(mockedDelete).toHaveBeenCalledWith(
'doc-content',
'http://test.jest/documents/123456/content/',
);
expect(mockedGetAllKeys).toHaveBeenCalledWith('doc-list');
expect(mockedGet).toHaveBeenCalledWith(
'doc-list',
@ -437,6 +521,15 @@ describe('ApiPlugin', () => {
expect.objectContaining({}),
'http://test.jest/documents/444555/',
);
expect(mockedPut).toHaveBeenCalledWith(
'doc-content',
expect.objectContaining({
content: '',
etag: '',
lastModified: '',
}),
'http://test.jest/documents/444555/content/',
);
expect(mockedPut).toHaveBeenCalledWith(
'doc-list',
expect.objectContaining({
@ -453,7 +546,7 @@ describe('ApiPlugin', () => {
'doc-list',
'http://test.jest/documents/?page=1',
);
expect(mockedPut).toHaveBeenCalledTimes(3);
expect(mockedPut).toHaveBeenCalledTimes(4);
expect(mockedClose).toHaveBeenCalled();
expect(response?.status).toBe(201);
});

View file

@ -2,6 +2,7 @@ import { WorkboxPlugin } from 'workbox-core';
import { Doc, DocsResponse } from '@/docs/doc-management';
import { LinkReach, LinkRole, Role } from '@/docs/doc-management/types';
import { UpdateDocContentParams } from '@/features/docs/doc-management/api/useDocContentUpdate';
import { DBRequest, DocsDB } from '../DocsDB';
import { RequestSerializer } from '../RequestSerializer';
@ -13,7 +14,7 @@ interface OptionsReadonly {
}
interface OptionsMutate {
type: 'update' | 'delete' | 'create';
type: 'update' | 'delete' | 'create' | 'content-update';
}
interface OptionsSync {
@ -130,6 +131,7 @@ export class ApiPlugin implements WorkboxPlugin {
requestWillFetch: WorkboxPlugin['requestWillFetch'] = async ({ request }) => {
if (
this.options.type === 'update' ||
this.options.type === 'content-update' ||
this.options.type === 'create' ||
this.options.type === 'delete'
) {
@ -173,6 +175,8 @@ export class ApiPlugin implements WorkboxPlugin {
return this.handlerDidErrorDelete(request);
case 'update':
return this.handlerDidErrorUpdate(request);
case 'content-update':
return this.handlerDidErrorContentUpdate(request);
case 'list':
case 'item':
return this.handlerDidErrorRead(this.options.tableName, request.url);
@ -183,6 +187,21 @@ export class ApiPlugin implements WorkboxPlugin {
return Promise.resolve(ApiPlugin.getApiCatchHandler());
};
private queueMutation = async (request: Request): Promise<void> => {
const requestData = (
await RequestSerializer.fromRequest(request)
).toObject();
const serializeRequest: DBRequest = {
requestData,
key: `${Date.now()}`,
};
await DocsDB.cacheResponse(
serializeRequest.key,
serializeRequest,
'doc-mutation',
);
};
private handlerDidErrorCreate = async (request: Request) => {
if (!this.initialRequest) {
return new Response('Request not found', { status: 404 });
@ -271,12 +290,26 @@ export class ApiPlugin implements WorkboxPlugin {
ancestors_link_role: undefined,
};
/**
* Create a new document in the cache with the new id, so the client can use it while offline,
* and it will be updated later when the request will be synced.
*/
await DocsDB.cacheResponse(
`${request.url}${uuid}/`,
newResponse,
'doc-item',
);
/**
* Create an empty content for the new document in the cache, so the client can use it while offline,
* and it will be updated later when the request will be synced.
*/
await DocsDB.cacheResponse(
`${request.url}${uuid}/content/`,
{ etag: '', lastModified: '', content: '' },
'doc-content',
);
/**
* Add the new entry to the cache list.
*/
@ -312,26 +345,14 @@ export class ApiPlugin implements WorkboxPlugin {
/**
* Queue the request in the cache 'doc-mutation' to sync it later.
*/
const requestData = (
await RequestSerializer.fromRequest(this.initialRequest)
).toObject();
const serializeRequest: DBRequest = {
requestData,
key: `${Date.now()}`,
};
await DocsDB.cacheResponse(
serializeRequest.key,
serializeRequest,
'doc-mutation',
);
await this.queueMutation(this.initialRequest);
/**
* Delete item in the cache
*/
const db = await DocsDB.open();
await db.delete('doc-item', request.url);
await db.delete('doc-content', `${request.url}content/`);
/**
* Delete entry from the cache list.
@ -378,20 +399,7 @@ export class ApiPlugin implements WorkboxPlugin {
/**
* Queue the request in the cache 'doc-mutation' to sync it later.
*/
const requestData = (
await RequestSerializer.fromRequest(this.initialRequest)
).toObject();
const serializeRequest: DBRequest = {
requestData,
key: `${Date.now()}`,
};
await DocsDB.cacheResponse(
serializeRequest.key,
serializeRequest,
'doc-mutation',
);
await this.queueMutation(this.initialRequest);
/**
* Update the cache item with the new data.
@ -489,4 +497,36 @@ export class ApiPlugin implements WorkboxPlugin {
},
});
};
/**
* When the content update fails, we save the new content in the cache, and we will sync it later with the SyncManager.
* We return a 204 to the client to say that the update is successful, and we update the content in the cache so the
* client can see the new content while offline.
*/
private handlerDidErrorContentUpdate = async (request: Request) => {
const db = await DocsDB.open();
const entry = await db.get('doc-content', request.url);
db.close();
if (!entry || !this.initialRequest) {
return new Response('Not found', { status: 404 });
}
await this.queueMutation(this.initialRequest);
const bodyMutate = (await this.initialRequest
.clone()
.json()) as Partial<UpdateDocContentParams>;
const newContent = bodyMutate.content ?? entry.content;
await DocsDB.cacheResponse(
request.url,
{ etag: '', lastModified: '', content: newContent },
'doc-content',
);
return new Response(null, {
status: 204,
statusText: 'No Content',
});
};
}

View file

@ -78,6 +78,31 @@ registerRoute(
'GET',
);
/**
* Mutate routes for the content update
* It will save in cache the request if the content update fails, and will retry
* to sync it later with the SyncManager
*/
registerRoute(
({ url }) =>
isApiUrl(url.href) && /\/documents\/[a-z0-9-]+\/content\//g.test(url.href),
new NetworkOnly({
plugins: [
new ApiPlugin({
type: 'content-update',
syncManager,
}),
new OfflinePlugin(),
],
}),
'PATCH',
);
/**
* Mutate routes for the document update
* It will save in cache the request if the document update fails, and will retry
* to sync it later with the SyncManager
*/
registerRoute(
({ url }) => isDocumentApiUrl(url),
new NetworkOnly({