From 94f08c0f04d489c6ec8d2af06690df7802ed6a05 Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:50:57 +0530 Subject: [PATCH] Keep edits made while Load latest is reading Load latest replaced the page with the returned remote revision even when the operator typed newer work while the read was pending. Capture the confirmed draft identity before the read, preserve a newer draft when it lands, keep the remote snapshot, and ask the operator to choose Load latest again to replace it. Unchanged confirmed drafts load exactly as before. Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> --- src/client/PageDocument.tsx | 14 +++- tests/page-load-latest.test.tsx | 121 ++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 tests/page-load-latest.test.tsx diff --git a/src/client/PageDocument.tsx b/src/client/PageDocument.tsx index f5a35e60..0bd1d01e 100644 --- a/src/client/PageDocument.tsx +++ b/src/client/PageDocument.tsx @@ -113,10 +113,20 @@ export function PageDocument({ ) ) return; + const confirmedDraft = controller.getSnapshot().draft; try { - controller.receive( - await api(`/spaces/${page.spaceId}/pages/${page.id}`), + const latestPage = await api( + `/spaces/${page.spaceId}/pages/${page.id}`, ); + if (!open.current) return; + const edited = controller.getSnapshot().draft !== confirmedDraft; + controller.receive(latestPage); + if (edited) { + setNotice( + 'Your draft changed while loading. Choose Load latest again to replace it.', + ); + return; + } controller.useLatest(); setNotice(''); } catch (error) { diff --git a/tests/page-load-latest.test.tsx b/tests/page-load-latest.test.tsx new file mode 100644 index 00000000..40ddf5ec --- /dev/null +++ b/tests/page-load-latest.test.tsx @@ -0,0 +1,121 @@ +import { createElement } from 'react'; +import { act, create } from 'react-test-renderer'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +const api = vi.hoisted(() => vi.fn()); +vi.mock('../src/client/api', () => ({ api })); +vi.mock('../src/client/editor/DocumentMenu', () => ({ + DocumentMenu: () => null, +})); +vi.mock('../src/client/editor/RichEditor', () => ({ default: () => null })); +vi.mock('../src/client/PageConversation', () => ({ + PageConversation: () => null, +})); +import { PageDocument } from '../src/client/PageDocument'; +const page = { + id: 'a', + spaceId: 'space', + parentId: null, + title: 'A', + content: 'text', + revision: 1, + createdAt: 0, + updatedAt: 0, + sourceThreadId: null, +}; +const props = { + page, + pages: [page], + workspace: {} as never, + paused: false, + onHome: vi.fn(), + onOutline: vi.fn(), + onSubpage: vi.fn(), + onDirty: vi.fn(), + onSaved: vi.fn(), + onRefresh: vi.fn(), + onDeleted: vi.fn(), + onSchedule: vi.fn(), + onThread: vi.fn(), + onSettings: vi.fn(), + onCreateDot: vi.fn(), +}; +let root: ReturnType; +beforeEach(() => { + api.mockReset(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('window', { + confirm: () => true, + addEventListener: () => {}, + removeEventListener: () => {}, + }); +}); +afterEach(async () => { + await act(async () => root.unmount()); +}); +it('preserves edits made after confirming Load latest while the read is pending', async () => { + await act(async () => { + root = create(createElement(PageDocument, props)); + }); + const title = () => root.root.findByProps({ 'aria-label': 'Page title' }); + await act(async () => { + title().props.onChange({ target: { value: 'My draft' } }); + }); + await act(async () => { + root.update( + createElement(PageDocument, { + ...props, + page: { ...page, title: 'Remote A', revision: 2 }, + }), + ); + }); + let finish!: (value: unknown) => void; + api.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const load = root.root + .findAllByType('button') + .find((b) => b.children.includes('Load latest'))!; + let pending!: Promise; + await act(async () => { + pending = load.props.onClick(); + }); + await act(async () => { + title().props.onChange({ + target: { value: 'Newer draft typed while loading' }, + }); + }); + await act(async () => { + finish({ ...page, title: 'Remote A', revision: 2 }); + await pending; + }); + expect(title().props.value).toBe('Newer draft typed while loading'); +}); + +it('loads the latest revision when the confirmed draft stays unchanged', async () => { + await act(async () => { + root = create(createElement(PageDocument, props)); + }); + const title = () => root.root.findByProps({ 'aria-label': 'Page title' }); + await act(async () => { + title().props.onChange({ target: { value: 'My draft' } }); + }); + await act(async () => { + root.update( + createElement(PageDocument, { + ...props, + page: { ...page, title: 'Remote A', revision: 2 }, + }), + ); + }); + api.mockResolvedValue({ ...page, title: 'Remote B', revision: 3 }); + const load = root.root + .findAllByType('button') + .find((b) => b.children.includes('Load latest'))!; + await act(async () => { + await load.props.onClick(); + }); + expect(title().props.value).toBe('Remote B'); +});