🔥(frontend) remove content GET PATCH

We remove the code related to the content GET and
PATCH endpoints, as they are no longer used in the
codebase. The yhub server will handle the content
management directly, providing the content and
managing the updates. This change simplifies the code
and reduces the complexity of the frontend application.

We will need to reimplement the saving mechanism
in the service worker when we are offline.
Let's wait that the service is fully developed
on the yhub side before we implement this feature.
This commit is contained in:
Anthony LC
2026-09-04 15:35:59 +02:00
parent 7e671ffd44
commit efe4d44316
13 changed files with 31 additions and 844 deletions
@@ -20,7 +20,6 @@ export const CONFIG = {
API_USERS_SEARCH_QUERY_MIN_LENGTH: 3,
COLLABORATION_WS_INACTIVITY_TIMEOUT: 15,
COLLABORATION_WS_URL: process.env.COLLABORATION_WS_URL,
COLLABORATION_WS_NOT_CONNECTED_READ_ONLY: true,
CONVERSION_UPLOAD_ENABLED: true,
CONVERSION_FILE_EXTENSIONS_ALLOWED: ['.docx', '.md'],
CONVERSION_FILE_MAX_SIZE: 20971520,
@@ -49,7 +49,6 @@ export interface ConfigResponse {
AI_FEATURE_LEGACY_ENABLED?: boolean;
API_USERS_SEARCH_QUERY_MIN_LENGTH?: number;
COLLABORATION_WS_URL?: string;
COLLABORATION_WS_NOT_CONNECTED_READ_ONLY?: boolean;
COLLABORATION_WS_INACTIVITY_TIMEOUT?: number | null;
CONVERSION_FILE_EXTENSIONS_ALLOWED: string[];
CONVERSION_FILE_MAX_SIZE: number;
@@ -50,7 +50,6 @@ import { useAnalytics } from '@/libs/Analytics';
import { AI_FEATURE_FLAG, DEFAULT_LOCALE } from '../conf';
import {
useHeadings,
useSaveDoc,
useScrollToBlockAnchor,
useShortcuts,
useUploadFile,
@@ -109,7 +108,6 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { setEditor } = useEditorStore();
const { themeTokens } = useCunninghamTheme();
const refEditorContainer = useRef<HTMLDivElement>(null);
useSaveDoc(doc.id, provider.doc);
const { i18n, t } = useTranslation();
const langLocalesBN =
@@ -1,258 +0,0 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { useRouter } from 'next/router';
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as Y from 'yjs';
import { AppWrapper } from '@/tests/utils';
import { useSaveDoc } from '../useSaveDoc';
vi.mock('next/router', () => ({
useRouter: vi.fn(),
}));
vi.mock('@/docs/doc-versioning', () => ({
KEY_LIST_DOC_VERSIONS: 'test-key-list-doc-versions',
}));
vi.mock('@/docs/doc-management', async () => ({
useUpdateDoc: (
await vi.importActual('@/docs/doc-management/api/useUpdateDoc')
).useUpdateDoc,
}));
describe('useSaveDoc', () => {
const mockRouterEvents = {
on: vi.fn(),
off: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
fetchMock.hardReset();
fetchMock.mockGlobal();
(useRouter as Mock).mockReturnValue({
events: mockRouterEvents,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should setup event listeners on mount', () => {
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
renderHook(() => useSaveDoc(docId, yDoc), {
wrapper: AppWrapper,
});
// Verify router event listeners are set up
expect(mockRouterEvents.on).toHaveBeenCalledWith(
'routeChangeStart',
expect.any(Function),
);
// Verify window event listener is set up
expect(addEventListenerSpy).toHaveBeenCalledWith(
'beforeunload',
expect.any(Function),
);
addEventListenerSpy.mockRestore();
});
it('should save when there are local changes', async () => {
vi.useFakeTimers();
const yDoc = new Y.Doc();
const docId = self.crypto.randomUUID();
fetchMock.patch(`http://test.jest/api/v1.0/documents/${docId}/content/`, {
body: JSON.stringify({
id: docId,
content: 'test-content',
}),
});
renderHook(() => useSaveDoc(docId, yDoc), {
wrapper: AppWrapper,
});
act(() => {
// Trigger a local update
yDoc.getMap('test').set('key', 'value');
});
act(() => {
// Advance timers to trigger the save interval
vi.advanceTimersByTime(61000);
});
// Switch to real timers to allow the mutation promise to resolve
vi.useRealTimers();
await waitFor(() => {
expect(fetchMock.callHistory.lastCall()?.url).toBe(
`http://test.jest/api/v1.0/documents/${docId}/content/`,
);
});
});
it('should not save when there are no local changes', () => {
vi.useFakeTimers();
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
fetchMock.patch(
'http://test.jest/api/v1.0/documents/test-doc-id/content/',
{
body: JSON.stringify({
id: 'test-doc-id',
content: 'test-content',
}),
},
);
renderHook(() => useSaveDoc(docId, yDoc), {
wrapper: AppWrapper,
});
act(() => {
// Advance timers without triggering any local updates
vi.advanceTimersByTime(61000);
});
// Since there are no local changes, no API call should be made
expect(fetchMock.callHistory.calls().length).toBe(0);
vi.useRealTimers();
});
const setupSavedDoc = async (yDoc: Y.Doc, docId: string) => {
fetchMock.patch(`http://test.jest/api/v1.0/documents/${docId}/content/`, {
body: JSON.stringify({ id: docId, content: 'test-content' }),
});
renderHook(() => useSaveDoc(docId, yDoc), {
wrapper: AppWrapper,
});
act(() => {
// Trigger a local update so there is something to save
yDoc.getMap('test').set('key', 'value');
});
};
const dispatchBeforeUnload = () => {
const event = new Event('beforeunload', { cancelable: true });
act(() => {
window.dispatchEvent(event);
});
return event;
};
it('should save with keepalive when the page is unloading', async () => {
const yDoc = new Y.Doc();
const docId = self.crypto.randomUUID();
await setupSavedDoc(yDoc, docId);
const event = dispatchBeforeUnload();
await waitFor(() => {
expect(fetchMock.callHistory.lastCall()?.url).toBe(
`http://test.jest/api/v1.0/documents/${docId}/content/`,
);
});
expect(fetchMock.callHistory.lastCall()?.options.keepalive).toBe(true);
// The browser owns the request, no need to hold the unload back
expect(event.defaultPrevented).toBe(false);
});
it('should not use keepalive when saving without unloading', async () => {
vi.useFakeTimers();
const yDoc = new Y.Doc();
const docId = self.crypto.randomUUID();
await setupSavedDoc(yDoc, docId);
act(() => {
vi.advanceTimersByTime(61000);
});
vi.useRealTimers();
await waitFor(() => {
expect(fetchMock.callHistory.lastCall()?.url).toBe(
`http://test.jest/api/v1.0/documents/${docId}/content/`,
);
});
expect(fetchMock.callHistory.lastCall()?.options.keepalive).toBeFalsy();
});
it('should hold the unload back when the doc is too big for keepalive with firefox', async () => {
const yDoc = new Y.Doc();
const docId = self.crypto.randomUUID();
// Mock Firefox user agent to simulate Firefox behavior
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/117.0',
);
fetchMock.patch(`http://test.jest/api/v1.0/documents/${docId}/content/`, {
body: JSON.stringify({ id: docId, content: 'test-content' }),
});
renderHook(() => useSaveDoc(docId, yDoc), {
wrapper: AppWrapper,
});
act(() => {
// Over the 64 KiB keepalive cap once base64 encoded
yDoc.getText('big').insert(0, 'a'.repeat(70 * 1024));
});
const event = dispatchBeforeUnload();
await waitFor(() => {
expect(fetchMock.callHistory.lastCall()?.url).toBe(
`http://test.jest/api/v1.0/documents/${docId}/content/`,
);
});
expect(fetchMock.callHistory.lastCall()?.options.keepalive).toBe(false);
// Regular fetch: the unload is held back so the request has time to go out
expect(event.defaultPrevented).toBe(true);
});
it('should cleanup event listeners on unmount', () => {
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc), {
wrapper: AppWrapper,
});
unmount();
// Verify router event listeners are cleaned up
expect(mockRouterEvents.off).toHaveBeenCalledWith(
'routeChangeStart',
expect.any(Function),
);
// Verify window event listener is cleaned up
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'beforeunload',
expect.any(Function),
);
});
});
@@ -1,5 +1,4 @@
export * from './useHeadings';
export * from './useSaveDoc';
export * from './useScrollToBlockAnchor';
export * from './useShortcuts';
export * from './useUploadFile';
@@ -3,10 +3,6 @@ import { useEffect } from 'react';
import { useCollaborationUrl, useConfig } from '@/core/config';
import { KEY_DOC } from '@/docs/doc-management/api/useDoc';
import {
KEY_DOC_CONTENT,
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';
@@ -33,13 +29,6 @@ export const useCollaboration = (room: string) => {
resumeFromInactivity,
} = useProviderStore();
const isOffline = useIsOffline((state) => state.isOffline);
const { data: docContent } = useDocContent(
{ id: room },
{
staleTime: 30000, // 30 seconds - We keep the data fresh as it is a highly collaborative page
queryKey: [KEY_DOC_CONTENT, { id: room }],
},
);
/**
* When offline, the WebSocket never connects so the provider would stay
@@ -89,20 +78,13 @@ export const useCollaboration = (room: string) => {
* Set the provider when the collaboration URL and the document content are available.
*/
useEffect(() => {
if (!room || !collaborationUrl || provider || docContent === undefined) {
if (!room || !collaborationUrl || provider) {
return;
}
const newProvider = createProvider(collaborationUrl, room, docContent);
const newProvider = createProvider(collaborationUrl, room);
setBroadcastProvider(newProvider);
}, [
provider,
collaborationUrl,
createProvider,
docContent,
room,
setBroadcastProvider,
]);
}, [provider, collaborationUrl, createProvider, room, setBroadcastProvider]);
/**
* Destroy the provider when the component is unmounted
@@ -1,162 +0,0 @@
import { useRouter } from 'next/router';
import { useCallback, useEffect, useRef, useState } from 'react';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
import {
canKeepaliveContent,
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 { COMMENT_UPDATE_ORIGIN } from '@/features/docs/doc-comments/api/DocsThreadStore';
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) => {
/**
* isSynced is more reliable than isConnected in this cases
* because it indicates that the content is fully synchronised
* with the yjs server
*/
const { isSynced: isConnectedToCollabServer } = useProviderStore();
const { isOffline } = useIsOffline();
const isSavingRef = useRef(false);
const { mutate: updateDocContent } = useDocContentUpdate({
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
isOptimistic: isOffline, // Enable optimistic updates when offline, to update the cache immediately
onSuccess: () => {
isSavingRef.current = false;
setIsLocalChange(false);
},
onError: () => {
isSavingRef.current = false;
},
});
const [isLocalChange, setIsLocalChange] = useState<boolean>(false);
/**
* Update initial doc when doc is updated by other users,
* so only the user typing will trigger the save.
* This is to avoid saving the same doc multiple time.
*/
useEffect(() => {
const onUpdate = (
_uintArray: Uint8Array,
_pluginKey: string,
_updatedDoc: Y.Doc,
transaction: Y.Transaction,
) => {
/**
* When the AI edit the doc transaction.local is false,
* so we check the transaction origin to know where
* the transaction comes from.
* "PluginKey" origin comes from the current user, but transaction.local is more reliable
* Updates from other users are applied by the collaboration server with
* the provider instance as origin, it seems quite reliable too.
* The AI origin seems to not be reliable enough, but by deduction if it's not local
* and not from other users, it has to be from the AI.
*
* TODO: see if we can get the local changes from the AI
*/
const isAIChange =
!transaction.local &&
!(transaction.origin instanceof WebsocketProvider);
/**
* notifySubscribers generate a transaction that can be
* interpreted as a local change.
* We intercept the update with this origin to
* avoid marking the change as local.
*/
if (transaction.origin === COMMENT_UPDATE_ORIGIN) {
return;
}
setIsLocalChange(transaction.local || isAIChange);
};
yDoc.on('update', onUpdate);
return () => {
yDoc.off('update', onUpdate);
};
}, [yDoc]);
/**
* `isSaving` tells whether a request was actually sent, `isKeptAlive`
* whether it was handed over to the browser process (see `keepalive`) and
* will therefore outlive the page.
*/
const saveDoc = useCallback(
({ isUnloading = false }: { isUnloading?: boolean } = {}) => {
if (!isLocalChange || isSavingRef.current) {
return { isSaving: false, isKeptAlive: false };
}
isSavingRef.current = true;
const content = toBase64(Y.encodeStateAsUpdate(yDoc));
const websocket = isConnectedToCollabServer;
updateDocContent({
id: docId,
content,
websocket,
keepalive: isUnloading,
});
return {
isSaving: true,
isKeptAlive: isUnloading && canKeepaliveContent({ content, websocket }),
};
},
[isLocalChange, updateDocContent, docId, yDoc, isConnectedToCollabServer],
);
const router = useRouter();
useEffect(() => {
const onSave = (e?: Event) => {
const isUnloading = typeof e !== 'undefined' && e.type === 'beforeunload';
const { isSaving, isKeptAlive } = saveDoc({ isUnloading });
/**
* Firefox does not trigger the request every time the user leaves the page.
* Plus the request is not intercepted by the service worker.
* So we prevent the default behavior to have the popup asking the user
* if he wants to leave the page, by adding the popup, we let the time to the
* request to be sent, and intercepted by the service worker (for the offline part).
*
* We do the same for documents too big to be sent with `keepalive`: the
* request is a regular fetch, so it dies with the page unless we hold
* the unload back.
*/
if (
isSaving &&
isUnloading &&
e.preventDefault &&
isFirefox() &&
!isKeptAlive
) {
e.preventDefault();
}
};
// Save every minute
const timeout = setInterval(() => onSave(), SAVE_INTERVAL);
// Save when the user leaves the page
addEventListener('beforeunload', onSave);
// Save when the user navigates to another page
router.events.on('routeChangeStart', onSave);
return () => {
clearInterval(timeout);
removeEventListener('beforeunload', onSave);
router.events.off('routeChangeStart', onSave);
};
}, [router.events, saveDoc]);
};
@@ -1,41 +0,0 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { validate as uuidValidate } from 'uuid';
import { APIError, errorCauses, fetchAPI } from '@/api';
export type DocContentParams = {
id: string;
};
export const getDocContent = async ({
id,
}: DocContentParams): Promise<string> => {
if (!uuidValidate(id)) {
throw new Error(`Invalid doc id in getDocContent: ${id}`);
}
const response = await fetchAPI(`documents/${id}/content/`, {
headers: {
accept: 'text/plain,application/json',
},
});
if (!response.ok) {
throw new APIError('Failed to get the doc', await errorCauses(response));
}
return response.text();
};
export const KEY_DOC_CONTENT = 'doc-content';
export function useDocContent(
param: DocContentParams,
queryConfig?: UseQueryOptions<string, APIError, string>,
) {
return useQuery<string, APIError, string>({
queryKey: queryConfig?.queryKey ?? [KEY_DOC_CONTENT, param],
queryFn: () => getDocContent(param),
...queryConfig,
});
}
@@ -1,146 +0,0 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { validate as uuidValidate } from 'uuid';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '../types';
import { KEY_DOC_CONTENT } from './useDocContent';
export interface UpdateDocContentParams {
id: Doc['id'];
content: string; // Base64 encoded content
websocket?: boolean;
/**
* Hand the request over to the browser process so it survives the page
* being torn down. Needed when saving from "beforeunload": a regular fetch
* is aborted with the document, and the server never sees the request.
*/
keepalive?: boolean;
}
/**
* The fetch spec caps the body of a keepalive request. Over that limit the
* browser rejects the request outright, so we fall back to a regular fetch.
*/
const KEEPALIVE_MAX_BODY_SIZE = 64 * 1024;
const buildContentBody = ({
content,
websocket,
}: Pick<UpdateDocContentParams, 'content' | 'websocket'>) =>
JSON.stringify({
content,
websocket,
});
/** Whether the doc is small enough to be saved with a keepalive request. */
export const canKeepaliveContent = (
params: Pick<UpdateDocContentParams, 'content' | 'websocket'>,
) => buildContentBody(params).length <= KEEPALIVE_MAX_BODY_SIZE;
export const updateDocContent = async ({
id,
content,
websocket,
keepalive,
}: UpdateDocContentParams): Promise<void> => {
if (!uuidValidate(id)) {
throw new Error(`Invalid doc id in updateDocContent: ${id}`);
}
const body = buildContentBody({ content, websocket });
const response = await fetchAPI(`documents/${id}/content/`, {
method: 'PATCH',
body,
keepalive: keepalive && body.length <= KEEPALIVE_MAX_BODY_SIZE,
});
if (!response.ok) {
throw new APIError(
'Failed to update the doc content',
await errorCauses(response),
);
}
};
type UseDocContentUpdate = UseMutationOptions<
void,
APIError,
UpdateDocContentParams
> & {
isOptimistic?: boolean;
listInvalidQueries?: string[];
};
export function useDocContentUpdate(queryConfig?: UseDocContentUpdate) {
const queryClient = useQueryClient();
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) {
const previousContent = queryClient.getQueryData([
KEY_DOC_CONTENT,
{ id: variables.id },
]);
queryClient.setQueryData(
[KEY_DOC_CONTENT, { id: variables.id }],
variables.content,
);
return { previousContent };
}
},
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],
});
});
if (queryConfig?.onSuccess) {
void queryConfig.onSuccess(data, variables, onMutateResult, context);
}
},
onError: (error, variables, onMutateResult, context) => {
if (
queryConfig?.isOptimistic &&
(onMutateResult as { previousContent: unknown })?.previousContent
) {
const previousContent = (onMutateResult as { previousContent: unknown })
.previousContent;
queryClient.setQueryData(
[KEY_DOC_CONTENT, { id: variables.id }],
previousContent,
);
}
if (queryConfig?.onError) {
queryConfig.onError(error, variables, onMutateResult, context);
}
},
});
}
@@ -5,16 +5,11 @@ import {
useQueryClient,
} from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning/api/useDocVersions';
import { toBase64 } from '@/utils/string';
import { useProviderStore } from '../stores';
import { Doc } from '../types';
import { useDocContentUpdate } from './useDocContentUpdate';
import { KEY_LIST_DOC } from './useDocs';
interface DuplicateDocPayload {
@@ -57,27 +52,10 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
const queryClient = useQueryClient();
const { toast } = useToastProvider();
const { t } = useTranslation();
const { provider } = useProviderStore();
const { mutateAsync: updateDocContent } = useDocContentUpdate({
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
});
return useMutation<DuplicateDocResponse, APIError, DuplicateDocParams>({
mutationFn: async (variables) => {
// Save the document if we can first, to ensure the latest state is duplicated
const canSave =
variables.canSave && provider && provider.doc.guid === variables.docId;
if (canSave) {
await updateDocContent({
id: variables.docId,
content: toBase64(Y.encodeStateAsUpdate(provider.doc)),
});
}
return await duplicateDoc(variables);
},
// TODO(yhub): double check the saving is made correctly from the back so
mutationFn: duplicateDoc,
onSuccess: (data, variables, onMutateResult, context) => {
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
@@ -1,22 +1,12 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/ui-components';
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/ui-components';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { Box, Text } from '@/components';
import { useThreadStore } from '@/docs/doc-comments/stores/useThreadStore';
import { Doc, base64ToYDoc, useProviderStore } from '@/docs/doc-management/';
import { useDocContentUpdate } from '@/docs/doc-management/api/useDocContentUpdate';
import { Doc } from '@/docs/doc-management/';
import { useDocVersion } from '../api';
import { KEY_LIST_DOC_VERSIONS } from '../api/useDocVersions';
import { Versions } from '../types';
import { revertUpdate } from '../utils';
const ModalStyle = createGlobalStyle`
.c__modal__title {
@@ -33,7 +23,7 @@ interface ModalConfirmationVersionProps {
export const ModalConfirmationVersion = ({
onClose,
onSuccess,
onSuccess: __onSuccess,
docId,
versionId,
}: ModalConfirmationVersionProps) => {
@@ -42,29 +32,28 @@ export const ModalConfirmationVersion = ({
versionId,
});
const { t } = useTranslation();
const { toast } = useToastProvider();
const { provider } = useProviderStore();
const { threadStore } = useThreadStore();
const { mutate: updateDocContent } = useDocContentUpdate({
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
onSuccess: () => {
const onDisplaySuccess = () => {
toast(t('Version restored successfully'), VariantType.SUCCESS);
onSuccess();
};
if (!provider || !version?.content) {
onDisplaySuccess();
return;
}
// TODO(yhub) : Revert the doc to a previous state using Y.js / Yhub
// const { mutate: updateDocContent } = useDocContentUpdate({
// listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
// onSuccess: () => {
// const onDisplaySuccess = () => {
// toast(t('Version restored successfully'), VariantType.SUCCESS);
// onSuccess();
// };
revertUpdate(provider.doc, provider.doc, base64ToYDoc(version.content));
// if (!provider || !version?.content) {
// onDisplaySuccess();
// return;
// }
threadStore?.refreshThreads();
// revertUpdate(provider.doc, provider.doc, base64ToYDoc(version.content));
onDisplaySuccess();
},
});
// threadStore?.refreshThreads();
// onDisplaySuccess();
// },
// });
if (!version) {
return null;
@@ -96,11 +85,6 @@ export const ModalConfirmationVersion = ({
return;
}
updateDocContent({
id: docId,
content: version.content,
});
onClose();
}}
>
@@ -2,19 +2,20 @@ 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';
import { SyncManager } from '../SyncManager';
interface OptionsReadonly {
tableName: 'doc-list' | 'doc-item' | 'doc-content';
type: 'list' | 'item' | 'content';
tableName: 'doc-list' | 'doc-item';
type: 'list' | 'item';
}
// TODO(yhub): Used to work offline, we need to implement the patch mechanism
// It will be probably linked to the HTTP fallback mechanism of yhub
interface OptionsMutate {
type: 'update' | 'delete' | 'create' | 'content-update';
type: 'update' | 'delete' | 'create';
}
interface OptionsSync {
@@ -53,27 +54,6 @@ export class ApiPlugin implements WorkboxPlugin {
response,
}) => {
try {
// For content requests, a 304 means the document hasn't changed:
// transparently serve the cached version from IDB.
if (this.options.type === 'content' && response.status === 304) {
const db = await DocsDB.open();
const entry = await db.get('doc-content', request.url);
db.close();
if (entry) {
return new Response(entry.content, {
status: 200,
statusText: 'OK',
headers: {
'Content-Type': 'text/plain',
...(entry.etag && { ETag: entry.etag }),
...(entry.lastModified && {
'Last-Modified': entry.lastModified,
}),
},
});
}
}
if (response.status !== 200) {
return response;
}
@@ -82,17 +62,6 @@ export class ApiPlugin implements WorkboxPlugin {
const tableName = this.options.tableName;
const body = (await response.clone().json()) as DocsResponse | Doc;
await DocsDB.cacheResponse(request.url, body, tableName);
} else if (this.options.type === 'content') {
// Cache the content response with its ETag / Last-Modified to be
// able to use it for conditional requests and offline access.
const content = await response.clone().text();
const etag = response.headers.get('ETag') ?? '';
const lastModified = response.headers.get('Last-Modified') ?? '';
await DocsDB.cacheResponse(
request.url,
{ etag, lastModified, content },
'doc-content',
);
} else if (this.options.type === 'update') {
const db = await DocsDB.open();
const storedResponse = await db.get('doc-item', request.url);
@@ -135,7 +104,6 @@ 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'
) {
@@ -144,27 +112,6 @@ export class ApiPlugin implements WorkboxPlugin {
await this.options.syncManager.sync();
// For content requests, add If-None-Match / If-Modified-Since from IDB
// so the backend can return a 304 when the document hasn't changed.
if (this.options.type === 'content') {
try {
const db = await DocsDB.open();
const entry = await db.get('doc-content', request.url);
db.close();
if (entry?.etag || entry?.lastModified) {
const headers = new Headers(request.headers);
if (entry.etag) {
headers.set('If-None-Match', entry.etag);
} else {
headers.set('If-Modified-Since', entry.lastModified);
}
return new Request(request, { headers });
}
} catch (error) {
console.error('SW: ApiPlugin requestWillFetch content error', error);
}
}
return Promise.resolve(request);
};
@@ -188,13 +135,9 @@ 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);
case 'content':
return this.handlerDidErrorContent(request);
}
return Promise.resolve(ApiPlugin.getApiCatchHandler());
@@ -492,56 +435,4 @@ export class ApiPlugin implements WorkboxPlugin {
},
});
};
private handlerDidErrorContent = async (request: Request) => {
const db = await DocsDB.open();
const entry = await db.get('doc-content', request.url);
db.close();
if (!entry) {
return Promise.resolve(ApiPlugin.getApiCatchHandler());
}
return new Response(entry.content, {
status: 200,
statusText: 'OK',
headers: {
'Content-Type': 'text/plain',
...(entry.etag && { ETag: entry.etag }),
...(entry.lastModified && { 'Last-Modified': entry.lastModified }),
},
});
};
/**
* 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',
});
};
}
@@ -62,42 +62,6 @@ registerRoute(
'GET',
);
registerRoute(
({ url }) =>
isApiUrl(url.href) && /\/documents\/[a-z0-9-]+\/content\/$/.test(url.href),
new NetworkOnly({
plugins: [
new ApiPlugin({
tableName: 'doc-content',
type: 'content',
syncManager,
}),
new OfflinePlugin(),
],
}),
'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\/$/.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