♻️(frontend) improve useSaveDoc hook
- add tests for useSaveDoc hook - simplify hooks states - reduce useEffect calls
This commit is contained in:
@@ -9,6 +9,7 @@ const createJestConfig = nextJest({
|
|||||||
const config: Config = {
|
const config: Config = {
|
||||||
coverageProvider: 'v8',
|
coverageProvider: 'v8',
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
|
'^@/docs/(.*)$': '<rootDir>/src/features/docs/$1',
|
||||||
'^@/(.*)$': '<rootDir>/src/$1',
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
},
|
},
|
||||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
import { useUpdateDoc } from '@/docs/doc-management/';
|
import { useUpdateDoc } from '@/docs/doc-management/';
|
||||||
@@ -8,17 +8,16 @@ import { isFirefox } from '@/utils/userAgent';
|
|||||||
|
|
||||||
import { toBase64 } from '../utils';
|
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({
|
const { mutate: updateDoc } = useUpdateDoc({
|
||||||
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
|
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsLocalChange(false);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const [initialDoc, setInitialDoc] = useState<string>(
|
const [isLocalChange, setIsLocalChange] = useState<boolean>(false);
|
||||||
toBase64(Y.encodeStateAsUpdate(doc)),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setInitialDoc(toBase64(Y.encodeStateAsUpdate(doc)));
|
|
||||||
}, [doc]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update initial doc when doc is updated by other users,
|
* 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 = (
|
const onUpdate = (
|
||||||
_uintArray: Uint8Array,
|
_uintArray: Uint8Array,
|
||||||
_pluginKey: string,
|
_pluginKey: string,
|
||||||
updatedDoc: Y.Doc,
|
_updatedDoc: Y.Doc,
|
||||||
transaction: Y.Transaction,
|
transaction: Y.Transaction,
|
||||||
) => {
|
) => {
|
||||||
if (!transaction.local) {
|
setIsLocalChange(transaction.local ? true : false);
|
||||||
setInitialDoc(toBase64(Y.encodeStateAsUpdate(updatedDoc)));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
doc.on('update', onUpdate);
|
yDoc.on('update', onUpdate);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
doc.off('update', onUpdate);
|
yDoc.off('update', onUpdate);
|
||||||
};
|
};
|
||||||
}, [doc]);
|
}, [yDoc]);
|
||||||
|
|
||||||
/**
|
|
||||||
* 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]);
|
|
||||||
|
|
||||||
const saveDoc = useCallback(() => {
|
const saveDoc = useCallback(() => {
|
||||||
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
|
if (!canSave || !isLocalChange) {
|
||||||
setInitialDoc(newDoc);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
updateDoc({
|
updateDoc({
|
||||||
id: docId,
|
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();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (timeout.current) {
|
|
||||||
clearTimeout(timeout.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSave = (e?: Event) => {
|
const onSave = (e?: Event) => {
|
||||||
if (!shouldSave()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveDoc();
|
saveDoc();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,21 +71,19 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Save every minute
|
// Save every minute
|
||||||
timeout.current = setInterval(onSave, 60000);
|
const timeout = setInterval(onSave, SAVE_INTERVAL);
|
||||||
// Save when the user leaves the page
|
// Save when the user leaves the page
|
||||||
addEventListener('beforeunload', onSave);
|
addEventListener('beforeunload', onSave);
|
||||||
// Save when the user navigates to another page
|
// Save when the user navigates to another page
|
||||||
router.events.on('routeChangeStart', onSave);
|
router.events.on('routeChangeStart', onSave);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (timeout.current) {
|
clearInterval(timeout);
|
||||||
clearTimeout(timeout.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeEventListener('beforeunload', onSave);
|
removeEventListener('beforeunload', onSave);
|
||||||
router.events.off('routeChangeStart', onSave);
|
router.events.off('routeChangeStart', onSave);
|
||||||
};
|
};
|
||||||
}, [router.events, saveDoc, shouldSave]);
|
}, [router.events, saveDoc]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useSaveDoc;
|
export default useSaveDoc;
|
||||||
|
|||||||
Reference in New Issue
Block a user