🐛(frontend) save the doc with a keepalive request when leaving the page

Since we fix the error in the backend application coming from middleware
not managing async request lifecycle, the e2e tests were failing. This
is because the browser cancel the request when made in the beforeunload
event. To fix it, we set the keepalive property on the fetch method to
True to not abort the request when the page is unloaded.
This commit is contained in:
Manuel Raynaud
2026-09-03 08:25:15 +02:00
parent c69e790703
commit b12b3b5dd6
4 changed files with 176 additions and 22 deletions
+1
View File
@@ -28,6 +28,7 @@ and this project adheres to
- 🐛(backend) fix duplicating a document that has no content
- 📄(frontend) allowed partially export when MIT #2551
- 🐛(backend) manage async support for Docs custom middleware
- 🐛(frontend) save the doc with a keepalive request when leaving the page
### Removed
@@ -1,7 +1,7 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { useRouter } from 'next/router';
import { Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as Y from 'yjs';
import { AppWrapper } from '@/tests/utils';
@@ -38,6 +38,10 @@ describe('useSaveDoc', () => {
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should setup event listeners on mount', () => {
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
@@ -129,6 +133,105 @@ describe('useSaveDoc', () => {
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';
@@ -2,7 +2,10 @@ import { useRouter } from 'next/router';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Y from 'yjs';
import { useDocContentUpdate } from '@/docs/doc-management/api/useDocContentUpdate';
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';
@@ -86,26 +89,41 @@ export const useSaveDoc = (docId: string, yDoc: Y.Doc) => {
};
}, [yDoc]);
const saveDoc = useCallback(() => {
if (!isLocalChange || isSavingRef.current) {
return false;
}
/**
* `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;
updateDocContent({
id: docId,
content: toBase64(Y.encodeStateAsUpdate(yDoc)),
websocket: isConnectedToCollabServer,
});
isSavingRef.current = true;
const content = toBase64(Y.encodeStateAsUpdate(yDoc));
const websocket = isConnectedToCollabServer;
updateDocContent({
id: docId,
content,
websocket,
keepalive: isUnloading,
});
return true;
}, [isLocalChange, updateDocContent, docId, yDoc, isConnectedToCollabServer]);
return {
isSaving: true,
isKeptAlive: isUnloading && canKeepaliveContent({ content, websocket }),
};
},
[isLocalChange, updateDocContent, docId, yDoc, isConnectedToCollabServer],
);
const router = useRouter();
useEffect(() => {
const onSave = (e?: Event) => {
const isSaving = saveDoc();
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.
@@ -113,19 +131,24 @@ export const useSaveDoc = (docId: string, yDoc: Y.Doc) => {
* 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 &&
typeof e !== 'undefined' &&
isUnloading &&
e.preventDefault &&
isFirefox()
isFirefox() &&
!isKeptAlive
) {
e.preventDefault();
}
};
// Save every minute
const timeout = setInterval(onSave, SAVE_INTERVAL);
const timeout = setInterval(() => onSave(), SAVE_INTERVAL);
// Save when the user leaves the page
addEventListener('beforeunload', onSave);
// Save when the user navigates to another page
@@ -16,23 +16,50 @@ 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: JSON.stringify({
content,
websocket,
}),
body,
keepalive: keepalive && body.length <= KEEPALIVE_MAX_BODY_SIZE,
});
if (!response.ok) {