👔(frontend) integrate dedicated content endpoint

To improve the performance of loading document content,
we have implemented a dedicated endpoint for
fetching document content. This allows us to load
the document metadata and content separately.
We updated the different components to utilize
this new endpoint, ensuring that the document content is
fetched and updated correctly.
This commit is contained in:
Anthony LC
2026-04-27 15:07:33 +02:00
committed by Manuel Raynaud
parent 68f1600c2b
commit b6c6fc8217
16 changed files with 258 additions and 118 deletions
@@ -250,22 +250,16 @@ export const waitForResponseCreateDoc = (page: Page) => {
};
export const mockedDocument = async (page: Page, data: object) => {
await page.route(/\**\/documents\/\**/, async (route) => {
// document/[ID]/ or document/[ID]/tree/ routes
await page.route(/.*\/documents\/[^/]+\/(?:$|tree\/.*)/, async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=') &&
!request.url().includes('versions') &&
!request.url().includes('accesses') &&
!request.url().includes('invitations')
) {
if (request.method().includes('GET') && !request.url().includes('page=')) {
const { abilities, ...doc } = data as unknown as {
abilities?: Record<string, unknown>;
};
await route.fulfill({
json: {
id: 'mocked-document-id',
content: '',
title: 'Mocked document',
path: '000000',
abilities: {
@@ -299,6 +293,17 @@ export const mockedDocument = async (page: Page, data: object) => {
await route.continue();
}
});
await page.route(/.*\/documents\/[^/]+\/content\/$/, async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
body: '',
});
} else {
await route.continue();
}
});
};
export const mockedListDocs = async (page: Page, data: object[] = []) => {
@@ -27,25 +27,16 @@ export const overrideDocContent = async ({
browserName: BrowserName;
}) => {
// Override content prop with assets/base-content-test-pdf.txt
await page.route(/\**\/documents\/\**/, async (route) => {
await page.route(/.*\/documents\/[^/]+\/content\/$/, async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=') &&
!request.url().includes('versions') &&
!request.url().includes('accesses') &&
!request.url().includes('invitations')
) {
if (request.method() === 'GET') {
const response = await route.fetch();
const json = await response.json();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
json.content = fs.readFileSync(
path.join(__dirname, 'assets/base-content-test-pdf.txt'),
'utf-8',
);
void route.fulfill({
response,
body: JSON.stringify(json),
body: fs.readFileSync(
path.join(__dirname, 'assets/base-content-test-pdf.txt'),
'utf-8',
),
});
} else {
await route.continue();
@@ -7,6 +7,7 @@ import {
Doc,
LinkReach,
getDocLinkReach,
useCollaboration,
useIsCollaborativeEditable,
useProviderStore,
} from '@/docs/doc-management';
@@ -79,6 +80,7 @@ interface DocEditorProps {
}
export const DocEditor = ({ doc }: DocEditorProps) => {
useCollaboration(doc.id);
const { isDesktop } = useResponsiveStore();
const { provider, isReady } = useProviderStore();
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
@@ -67,13 +67,15 @@ describe('useSaveDoc', () => {
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
content: 'test-content',
title: 'test-title',
}),
});
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, true), {
wrapper: AppWrapper,
@@ -94,7 +96,7 @@ describe('useSaveDoc', () => {
await waitFor(() => {
expect(fetchMock.lastCall()?.[0]).toBe(
'http://test.jest/api/v1.0/documents/test-doc-id/',
'http://test.jest/api/v1.0/documents/test-doc-id/content/',
);
});
});
@@ -104,13 +106,15 @@ describe('useSaveDoc', () => {
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
content: 'test-content',
title: 'test-title',
}),
});
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, true), {
wrapper: AppWrapper,
@@ -1,8 +1,9 @@
import { useRouter } from 'next/router';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Y from 'yjs';
import { useUpdateDoc } from '@/docs/doc-management/';
import { KEY_DOC_CONTENT } from '@/docs//doc-management/api/useDocContent';
import { useDocContentUpdate } from '@/docs/doc-management/api/useDocContentUpdate';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning/api/useDocVersions';
import { toBase64 } from '@/utils/string';
import { isFirefox } from '@/utils/userAgent';
@@ -14,11 +15,16 @@ export const useSaveDoc = (
yDoc: Y.Doc,
isConnectedToCollabServer: boolean,
) => {
const { mutate: updateDoc } = useUpdateDoc({
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
const isSavingRef = useRef(false);
const { mutate: updateDocContent } = useDocContentUpdate({
listInvalidQueries: [KEY_LIST_DOC_VERSIONS, KEY_DOC_CONTENT],
onSuccess: () => {
isSavingRef.current = false;
setIsLocalChange(false);
},
onError: () => {
isSavingRef.current = false;
},
});
const [isLocalChange, setIsLocalChange] = useState<boolean>(false);
@@ -64,18 +70,19 @@ export const useSaveDoc = (
}, [yDoc]);
const saveDoc = useCallback(() => {
if (!isLocalChange) {
if (!isLocalChange || isSavingRef.current) {
return false;
}
updateDoc({
isSavingRef.current = true;
updateDocContent({
id: docId,
content: toBase64(Y.encodeStateAsUpdate(yDoc)),
websocket: isConnectedToCollabServer,
});
return true;
}, [isLocalChange, updateDoc, docId, yDoc, isConnectedToCollabServer]);
}, [isLocalChange, updateDocContent, docId, yDoc, isConnectedToCollabServer]);
const router = useRouter();
@@ -0,0 +1,41 @@
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,
});
}
@@ -0,0 +1,76 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '../types';
import { KEY_CAN_EDIT } from './useDocCanEdit';
interface UpdateDocContentParams {
id: Doc['id'];
content: string; // Base64 encoded content
websocket?: boolean;
}
export const updateDocContent = async ({
id,
content,
websocket,
}: UpdateDocContentParams): Promise<void> => {
const response = await fetchAPI(`documents/${id}/content/`, {
method: 'PATCH',
body: JSON.stringify({
content,
websocket,
}),
});
if (!response.ok) {
throw new APIError(
'Failed to update the doc content',
await errorCauses(response),
);
}
};
type UseDocContentUpdate = UseMutationOptions<
void,
APIError,
UpdateDocContentParams
> & {
listInvalidQueries?: string[];
};
export function useDocContentUpdate(queryConfig?: UseDocContentUpdate) {
const queryClient = useQueryClient();
return useMutation<void, APIError, UpdateDocContentParams>({
mutationFn: updateDocContent,
...queryConfig,
onSuccess: (data, variables, onMutateResult, context) => {
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 error it means the user is probably not allowed to edit the doc
// so we invalidate the canEdit query to update the UI accordingly
void queryClient.invalidateQueries({
queryKey: [KEY_CAN_EDIT],
});
if (queryConfig?.onError) {
queryConfig.onError(error, variables, onMutateResult, context);
}
},
});
}
@@ -17,8 +17,9 @@ 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';
import { useUpdateDoc } from './useUpdateDoc';
interface DuplicateDocPayload {
docId: string;
@@ -62,8 +63,8 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
const { t } = useTranslation();
const { provider } = useProviderStore();
const { mutateAsync: updateDoc } = useUpdateDoc({
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
const { mutateAsync: updateDocContent } = useDocContentUpdate({
listInvalidQueries: [KEY_LIST_DOC_VERSIONS, KEY_DOC_CONTENT],
});
return useMutation<DuplicateDocResponse, APIError, DuplicateDocParams>({
@@ -75,7 +76,7 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
provider.document.guid === variables.docId;
if (canSave) {
await updateDoc({
await updateDocContent({
id: variables.docId,
content: toBase64(Y.encodeStateAsUpdate(provider.document)),
});
@@ -8,12 +8,10 @@ import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '../types';
import { KEY_CAN_EDIT } from './useDocCanEdit';
export type UpdateDocParams = Pick<Doc, 'id'> &
Partial<Pick<Doc, 'content' | 'title'>> & {
websocket?: boolean;
};
export interface UpdateDocParams {
id: Doc['id'];
title?: string;
}
export const updateDoc = async ({
id,
@@ -33,7 +31,7 @@ export const updateDoc = async ({
return response.json() as Promise<Doc>;
};
type UseUpdateDoc = UseMutationOptions<Doc, APIError, Partial<Doc>> & {
type UseUpdateDoc = UseMutationOptions<Doc, APIError, UpdateDocParams> & {
listInvalidQueries?: string[];
};
@@ -54,12 +52,6 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
}
},
onError: (error, variables, onMutateResult, context) => {
// If error it means the user is probably not allowed to edit the doc
// so we invalidate the canEdit query to update the UI accordingly
void queryClient.invalidateQueries({
queryKey: [KEY_CAN_EDIT],
});
if (queryConfig?.onError) {
queryConfig.onError(error, variables, onMutateResult, context);
}
@@ -15,6 +15,7 @@ import { useConfig } from '@/core';
import { KEY_LIST_DOC_TRASHBIN } from '@/docs/docs-grid';
import { useKeyboardAction } from '@/hooks';
import { KEY_DOC } from '../api';
import { KEY_LIST_DOC } from '../api/useDocs';
import { useRemoveDoc } from '../api/useRemoveDoc';
import { useDocUtils } from '../hooks';
@@ -44,7 +45,7 @@ export const ModalRemoveDoc = ({
isError,
error,
} = useRemoveDoc({
listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_DOC_TRASHBIN],
listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_DOC_TRASHBIN, KEY_DOC],
options: {
onSuccess: () => {
if (onSuccess) {
@@ -1,29 +1,83 @@
import { useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useCollaborationUrl } from '@/core/config';
import {
KEY_DOC_CONTENT,
useDocContent,
} from '@/docs/doc-management/api/useDocContent';
import { useProviderStore } from '@/docs/doc-management/stores/useProviderStore';
import { useBroadcastStore } from '@/stores/useBroadcastStore';
import { useProviderStore } from '../stores/useProviderStore';
import { Base64 } from '../types';
import { KEY_DOC } from '../api';
export const useCollaboration = (room?: string, initialContent?: Base64) => {
export const useCollaboration = (room: string) => {
const collaborationUrl = useCollaborationUrl(room);
const { addTask } = useBroadcastStore();
const queryClient = useQueryClient();
const { setBroadcastProvider, cleanupBroadcast } = useBroadcastStore();
const { provider, createProvider, destroyProvider } = useProviderStore();
const {
provider,
createProvider,
destroyProvider,
isReady,
hasLostConnection,
resetLostConnection,
} = useProviderStore();
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 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
* (e.g., permissions changed, document deleted, user removed)
*/
useEffect(() => {
if (!room || !collaborationUrl || provider) {
if (hasLostConnection && room) {
void queryClient.invalidateQueries({
queryKey: [KEY_DOC, { id: room }],
});
resetLostConnection();
}
}, [hasLostConnection, room, queryClient, resetLostConnection]);
/**
* We add a broadcast task to reset the query cache
* when the document visibility changes.
*/
useEffect(() => {
if (!room || !isReady) {
return;
}
const newProvider = createProvider(collaborationUrl, room, initialContent);
addTask(`${KEY_DOC}-${room}`, () => {
void queryClient.invalidateQueries({
queryKey: [KEY_DOC, { id: room }],
});
});
}, [addTask, room, queryClient, isReady]);
/**
* Set the provider when the collaboration URL and the document content are available.
*/
useEffect(() => {
if (!room || !collaborationUrl || provider || docContent === undefined) {
return;
}
const newProvider = createProvider(collaborationUrl, room, docContent);
setBroadcastProvider(newProvider);
}, [
provider,
collaborationUrl,
room,
initialContent,
createProvider,
docContent,
room,
setBroadcastProvider,
]);
@@ -53,7 +53,6 @@ export interface Doc {
title?: string;
children?: Doc[];
childrenCount?: number;
content?: Base64;
created_at: string;
creator: string;
deleted_at: string | null;
@@ -82,9 +81,12 @@ export interface Doc {
children_list: boolean;
collaboration_auth: boolean;
comment: boolean;
content_patch: boolean;
content_retrieve: boolean;
destroy: boolean;
duplicate: boolean;
favorite: boolean;
formatted_content: boolean;
invite_owner: boolean;
link_configuration: boolean;
media_auth: boolean;
@@ -10,12 +10,8 @@ import { createGlobalStyle } from 'styled-components';
import { Box, Text } from '@/components';
import { useEditorStore } from '@/docs/doc-editor/stores';
import {
Doc,
base64ToYDoc,
useProviderStore,
useUpdateDoc,
} from '@/docs/doc-management/';
import { Doc, base64ToYDoc, useProviderStore } from '@/docs/doc-management/';
import { useDocContentUpdate } from '@/docs/doc-management/api/useDocContentUpdate';
import { useDocVersion } from '../api';
import { KEY_LIST_DOC_VERSIONS } from '../api/useDocVersions';
@@ -49,7 +45,7 @@ export const ModalConfirmationVersion = ({
const { toast } = useToastProvider();
const { provider } = useProviderStore();
const { threadStore } = useEditorStore();
const { mutate: updateDoc } = useUpdateDoc({
const { mutate: updateDocContent } = useDocContentUpdate({
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
onSuccess: () => {
const onDisplaySuccess = () => {
@@ -104,7 +100,7 @@ export const ModalConfirmationVersion = ({
return;
}
updateDoc({
updateDocContent({
id: docId,
content: version.content,
});
@@ -1,5 +1,3 @@
import { Doc } from '../doc-management/types';
export interface APIListVersions {
count: number;
is_truncated: boolean;
@@ -15,7 +13,7 @@ export interface Versions {
}
export interface Version {
content: Doc['content'];
content: string; // Base64 encoded content
last_modified: string;
id: string;
}
@@ -169,7 +169,6 @@ export class ApiPlugin implements WorkboxPlugin {
const newResponse: Doc = {
title: '',
id: uuid,
content: '',
created_at: new Date().toISOString(),
creator: 'dummy-id',
deleted_at: null,
@@ -190,9 +189,12 @@ export class ApiPlugin implements WorkboxPlugin {
children_list: true,
collaboration_auth: true,
comment: true,
content_patch: true,
content_retrieve: true,
destroy: true,
duplicate: true,
favorite: true,
formatted_content: true,
invite_owner: true,
link_configuration: true,
media_auth: true,
@@ -12,10 +12,8 @@ import {
Doc,
DocPage403,
KEY_DOC,
useCollaboration,
useDoc,
useDocStore,
useProviderStore,
useTrans,
} from '@/docs/doc-management/';
import { KEY_AUTH, setAuthUrl, useAuth } from '@/features/auth';
@@ -24,7 +22,6 @@ import { getDocChildren, subPageToTree } from '@/features/docs/doc-tree/';
import { DocEditorSkeleton, useSkeletonStore } from '@/features/skeletons';
import { MainLayout } from '@/layouts';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { useBroadcastStore } from '@/stores/useBroadcastStore';
import { NextPageWithLayout } from '@/types/next';
const DocEditor = dynamic(
@@ -78,7 +75,6 @@ interface DocProps {
}
const DocPage = ({ id }: DocProps) => {
const { hasLostConnection, resetLostConnection } = useProviderStore();
const { isSkeletonVisible, setIsSkeletonVisible } = useSkeletonStore();
const {
data: docQuery,
@@ -88,7 +84,7 @@ const DocPage = ({ id }: DocProps) => {
} = useDoc(
{ id },
{
staleTime: 0,
staleTime: 30000, // 30 seconds - We keep the data fresh as it is a highly collaborative page
queryKey: [KEY_DOC, { id }],
retryDelay: 1000,
retry: (failureCount, error) => {
@@ -103,10 +99,8 @@ const DocPage = ({ id }: DocProps) => {
const [doc, setDoc] = useState<Doc>();
const { setCurrentDoc } = useDocStore();
const { addTask } = useBroadcastStore();
const queryClient = useQueryClient();
const { replace, asPath } = useRouter();
useCollaboration(doc?.id, doc?.content);
const { t } = useTranslation();
const { authenticated } = useAuth();
const { untitledDocument } = useTrans();
@@ -144,16 +138,6 @@ const DocPage = ({ id }: DocProps) => {
};
}, [id]);
// Invalidate when provider store reports a lost connection
useEffect(() => {
if (hasLostConnection && doc?.id) {
void queryClient.invalidateQueries({
queryKey: [KEY_DOC, { id: doc.id }],
});
resetLostConnection();
}
}, [hasLostConnection, doc?.id, queryClient, resetLostConnection]);
useEffect(() => {
if (!docQuery || isFetching) {
return;
@@ -174,22 +158,6 @@ const DocPage = ({ id }: DocProps) => {
};
}, [setCurrentDoc, setIsSkeletonVisible]);
/**
* We add a broadcast task to reset the query cache
* when the document visibility changes.
*/
useEffect(() => {
if (!doc?.id) {
return;
}
addTask(`${KEY_DOC}-${doc.id}`, () => {
void queryClient.invalidateQueries({
queryKey: [KEY_DOC, { id: doc.id }],
});
});
}, [addTask, doc?.id, queryClient]);
useEffect(() => {
if (!isError || !error?.status || [403].includes(error.status)) {
return;