♻️(frontend) improve useSaveDoc hook

- add tests for useSaveDoc hook
- simplify hooks states
- reduce useEffect calls
This commit is contained in:
Anthony LC
2025-03-17 15:02:14 +01:00
committed by Anthony LC
parent f2ed8e0ea1
commit d5d2cfab8e
3 changed files with 207 additions and 46 deletions

View File

@@ -9,6 +9,7 @@ const createJestConfig = nextJest({
const config: Config = {
coverageProvider: 'v8',
moduleNameMapper: {
'^@/docs/(.*)$': '<rootDir>/src/features/docs/$1',
'^@/(.*)$': '<rootDir>/src/$1',
},
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],

View File

@@ -0,0 +1,185 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { useRouter } from 'next/router';
import * as Y from 'yjs';
import { AppWrapper } from '@/tests/utils';
import useSaveDoc from '../useSaveDoc';
jest.mock('next/router', () => ({
useRouter: jest.fn(),
}));
jest.mock('@/docs/doc-versioning', () => ({
KEY_LIST_DOC_VERSIONS: 'test-key-list-doc-versions',
}));
jest.mock('@/docs/doc-management', () => ({
useUpdateDoc: jest.requireActual('@/docs/doc-management/api/useUpdateDoc')
.useUpdateDoc,
}));
describe('useSaveDoc', () => {
const mockRouterEvents = {
on: jest.fn(),
off: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
fetchMock.restore();
(useRouter as jest.Mock).mockReturnValue({
events: mockRouterEvents,
});
});
it('should setup event listeners on mount', () => {
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
renderHook(() => useSaveDoc(docId, yDoc, true), {
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 not save when canSave is false', async () => {
jest.useFakeTimers();
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',
}),
});
renderHook(() => useSaveDoc(docId, yDoc, false), {
wrapper: AppWrapper,
});
act(() => {
// Trigger a local update
yDoc.getMap('test').set('key', 'value');
});
act(() => {
// Now advance timers after state has updated
jest.advanceTimersByTime(61000);
});
await waitFor(() => {
expect(fetchMock.calls().length).toBe(0);
});
jest.useRealTimers();
});
it('should save when there are local changes', async () => {
jest.useFakeTimers();
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',
}),
});
renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});
act(() => {
// Trigger a local update
yDoc.getMap('test').set('key', 'value');
});
act(() => {
// Now advance timers after state has updated
jest.advanceTimersByTime(61000);
});
await waitFor(() => {
expect(fetchMock.lastCall()?.[0]).toBe(
'http://test.jest/api/v1.0/documents/test-doc-id/',
);
});
jest.useRealTimers();
});
it('should not save when there are no local changes', async () => {
jest.useFakeTimers();
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',
}),
});
renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});
act(() => {
// Now advance timers after state has updated
jest.advanceTimersByTime(61000);
});
await waitFor(() => {
expect(fetchMock.calls().length).toBe(0);
});
jest.useRealTimers();
});
it('should cleanup event listeners on unmount', () => {
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), {
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),
);
});
});

View File

@@ -1,5 +1,5 @@
import { useRouter } from 'next/router';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import * as Y from 'yjs';
import { useUpdateDoc } from '@/docs/doc-management/';
@@ -8,17 +8,16 @@ import { isFirefox } from '@/utils/userAgent';
import { toBase64 } from '../utils';
const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
const SAVE_INTERVAL = 60000;
const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
onSuccess: () => {
setIsLocalChange(false);
},
});
const [initialDoc, setInitialDoc] = useState<string>(
toBase64(Y.encodeStateAsUpdate(doc)),
);
useEffect(() => {
setInitialDoc(toBase64(Y.encodeStateAsUpdate(doc)));
}, [doc]);
const [isLocalChange, setIsLocalChange] = useState<boolean>(false);
/**
* Update initial doc when doc is updated by other users,
@@ -29,56 +28,34 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
const onUpdate = (
_uintArray: Uint8Array,
_pluginKey: string,
updatedDoc: Y.Doc,
_updatedDoc: Y.Doc,
transaction: Y.Transaction,
) => {
if (!transaction.local) {
setInitialDoc(toBase64(Y.encodeStateAsUpdate(updatedDoc)));
}
setIsLocalChange(transaction.local ? true : false);
};
doc.on('update', onUpdate);
yDoc.on('update', onUpdate);
return () => {
doc.off('update', onUpdate);
yDoc.off('update', onUpdate);
};
}, [doc]);
/**
* Check if the doc has been updated and can be saved.
*/
const hasChanged = useCallback(() => {
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
return initialDoc !== newDoc;
}, [doc, initialDoc]);
const shouldSave = useCallback(() => {
return hasChanged() && canSave;
}, [canSave, hasChanged]);
}, [yDoc]);
const saveDoc = useCallback(() => {
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
setInitialDoc(newDoc);
if (!canSave || !isLocalChange) {
return;
}
updateDoc({
id: docId,
content: newDoc,
content: toBase64(Y.encodeStateAsUpdate(yDoc)),
});
}, [doc, docId, updateDoc]);
}, [canSave, yDoc, docId, isLocalChange, updateDoc]);
const timeout = useRef<NodeJS.Timeout | null>(null);
const router = useRouter();
useEffect(() => {
if (timeout.current) {
clearTimeout(timeout.current);
}
const onSave = (e?: Event) => {
if (!shouldSave()) {
return;
}
saveDoc();
/**
@@ -94,21 +71,19 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
};
// Save every minute
timeout.current = setInterval(onSave, 60000);
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 () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
clearInterval(timeout);
removeEventListener('beforeunload', onSave);
router.events.off('routeChangeStart', onSave);
};
}, [router.events, saveDoc, shouldSave]);
}, [router.events, saveDoc]);
};
export default useSaveDoc;