Skip to content

Commit 110e381

Browse files
appflowyclaude
andauthored
fix: render embedded database in version history preview (#385)
Embedded databases inside a document showed an infinite loading spinner when previewed in Version history. The version-preview <Editor> was not given `loadView`/`bindViewSync` from the editor context, so the embedded DatabaseBlock's useDocumentLoader bailed out ("loadView not available") and the database collab doc never loaded, leaving DatabaseContent stuck on a perpetual CircularProgress. Forward `loadView` and `bindViewSync` (already exposed by useAppOperations and used by the normal ViewModal path) into the preview editor. The preview shows the database's live data, matching the normal view — databases are separate collab objects not captured in the document version snapshot. Also applies a few React best-practice cleanups in the same file: - hoist the static MUI Dialog PaperProps out of render - reuse the existing handleClose callback for Dialog onClose - drop the redundant handleSetDateFilter wrapper around setDateFilter Tests: - unit (DocumentHistoryModal.test.tsx): asserts the preview Editor receives loadView/bindViewSync; fails without the fix. - e2e (version-history-database.spec.ts): signs in to the seeded account, opens the seeded page's Version history, and asserts the embedded grid renders with rows (not an infinite spinner). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7cfb528 commit 110e381

3 files changed

Lines changed: 228 additions & 14 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
import { signInWithPasswordViaUi } from '../../support/auth-flow-helpers';
4+
import {
5+
HeaderSelectors,
6+
VersionHistorySelectors,
7+
DatabaseGridSelectors,
8+
} from '../../support/selectors';
9+
import { testLog } from '../../support/test-helpers';
10+
11+
/**
12+
* Version History — embedded database rendering
13+
*
14+
* Regression coverage for the bug where an embedded database inside a document
15+
* showed an infinite loading spinner when previewed in Version history. The
16+
* version-preview <Editor/> was not given `loadView`/`bindViewSync`, so the
17+
* embedded database could never load its collab doc.
18+
*
19+
* This is a BDD-style scenario against a seeded account/page:
20+
* GIVEN a signed-in user on a document that embeds a database and has version history
21+
* WHEN they open Version history
22+
* THEN the embedded database renders (grid + rows) instead of spinning forever.
23+
*
24+
* Note: the preview intentionally shows the database's *live* data (databases are
25+
* separate collab objects not captured in the document snapshot), so this test
26+
* only asserts that the grid renders — not that it matches a historical state.
27+
*/
28+
29+
// Seeded account + page. Overridable via env so CI can point at its own fixture.
30+
const SEEDED_USER_EMAIL = process.env.SEEDED_USER_EMAIL || 'nathan@appflowy.io';
31+
const SEEDED_USER_PASSWORD = process.env.SEEDED_USER_PASSWORD || 'AppFlowy!@123';
32+
const SEEDED_WORKSPACE_ID =
33+
process.env.SEEDED_WORKSPACE_ID || '997c87ed-1667-4a62-8c0a-a74ee1aadb4b';
34+
const SEEDED_VIEW_ID = process.env.SEEDED_VIEW_ID || 'da539c35-a852-434c-88a9-52fc086c1551';
35+
36+
test.describe('Version History — embedded database', () => {
37+
test.beforeEach(async ({ page }) => {
38+
page.on('pageerror', () => {
39+
// Suppress uncaught exceptions from unrelated app code
40+
});
41+
42+
await page.setViewportSize({ width: 1440, height: 960 });
43+
44+
testLog.step(1, `Sign in as seeded user (${SEEDED_USER_EMAIL})`);
45+
await signInWithPasswordViaUi(page, SEEDED_USER_EMAIL, SEEDED_USER_PASSWORD);
46+
});
47+
48+
test('renders the embedded database in the version-history preview (no infinite spinner)', async ({
49+
page,
50+
}) => {
51+
testLog.step(2, 'Open the seeded page that embeds a database');
52+
await page.goto(`/app/${SEEDED_WORKSPACE_ID}/${SEEDED_VIEW_ID}`, {
53+
waitUntil: 'domcontentloaded',
54+
});
55+
await expect(page).toHaveURL(new RegExp(`/app/${SEEDED_WORKSPACE_ID}/${SEEDED_VIEW_ID}`), {
56+
timeout: 30000,
57+
});
58+
59+
// Confirm the page itself loaded with its embedded database (baseline).
60+
await expect(DatabaseGridSelectors.grid(page).first()).toBeVisible({ timeout: 30000 });
61+
62+
testLog.step(3, 'Open the "More actions" menu and click Version history');
63+
await expect(HeaderSelectors.moreActionsButton(page)).toBeVisible({ timeout: 15000 });
64+
await HeaderSelectors.moreActionsButton(page).click();
65+
66+
await expect(VersionHistorySelectors.menuItem(page)).toBeVisible({ timeout: 10000 });
67+
await VersionHistorySelectors.menuItem(page).click();
68+
69+
testLog.step(4, 'Wait for the version-history modal and a selected version');
70+
const modal = VersionHistorySelectors.modal(page);
71+
await expect(modal).toBeVisible({ timeout: 15000 });
72+
// At least one version must exist for a preview to render.
73+
await expect(VersionHistorySelectors.items(page).first()).toBeVisible({ timeout: 15000 });
74+
75+
testLog.step(5, 'Assert the embedded database renders inside the preview');
76+
// The core regression assertion: the grid appears inside the modal.
77+
// Before the fix this never resolved (perpetual CircularProgress).
78+
const previewGrid = modal.getByTestId('database-grid');
79+
await expect(previewGrid).toBeVisible({ timeout: 30000 });
80+
81+
testLog.step(6, 'Assert the preview grid actually has data rows');
82+
const previewRows = modal.locator('[data-testid^="grid-row-"]:not([data-testid="grid-row-undefined"])');
83+
await expect(previewRows.first()).toBeVisible({ timeout: 30000 });
84+
expect(await previewRows.count()).toBeGreaterThan(0);
85+
86+
testLog.step(7, 'Close the version-history modal');
87+
await VersionHistorySelectors.closeButton(page).click();
88+
await expect(modal).not.toBeVisible({ timeout: 10000 });
89+
});
90+
});

src/components/document/history/DocumentHistoryModal.tsx

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,14 @@ import { VersionList } from './DocumentHistoryVersionList';
2626

2727
type PreviewEditorProps = Pick<
2828
EditorContextState,
29-
'loadViewMeta' | 'createRow' | 'eventEmitter' | 'getMentionUser' | 'getViewIdFromDatabaseId' | 'loadDatabaseRelations'
29+
| 'loadView'
30+
| 'bindViewSync'
31+
| 'loadViewMeta'
32+
| 'createRow'
33+
| 'eventEmitter'
34+
| 'getMentionUser'
35+
| 'getViewIdFromDatabaseId'
36+
| 'loadDatabaseRelations'
3037
>;
3138

3239
type VersionPreviewBodyProps = {
@@ -72,6 +79,15 @@ const VersionPreviewBody = memo(function VersionPreviewBody({
7279
);
7380
});
7481

82+
// Static, so hoist it out of render: avoids recreating the object (and re-running
83+
// `cn`) on every render and handing MUI's Dialog a fresh `PaperProps` each time.
84+
const DIALOG_PAPER_PROPS = {
85+
className: cn(
86+
'flex !h-full !w-full overflow-hidden rounded-2xl bg-surface-layer-02',
87+
'!max-h-[min(920px,_calc(100vh-160px))] !min-h-[min(689px,_calc(100vh-40px))] !min-w-[min(984px,_calc(100vw-40px))] !max-w-[min(1680px,_calc(100vw-240px))]'
88+
),
89+
};
90+
7591
export function DocumentHistoryModal({
7692
open,
7793
onOpenChange,
@@ -86,7 +102,7 @@ export function DocumentHistoryModal({
86102
icon: ViewIcon | null;
87103
};
88104
}) {
89-
const { loadViewMeta, createRow, getViewIdFromDatabaseId } = useAppOperations();
105+
const { loadViewMeta, createRow, getViewIdFromDatabaseId, loadView, bindViewSync } = useAppOperations();
90106
const { getCollabHistory, previewCollabVersion, revertCollabVersion } = useCollabHistory();
91107
const getSubscriptions = useGetSubscriptions();
92108
const eventEmitter = useEventEmitter();
@@ -179,10 +195,6 @@ export function DocumentHistoryModal({
179195
}
180196
}, [viewId, getCollabHistory]);
181197

182-
const handleSetDateFilter = useCallback((filter: 'all' | 'last7Days' | 'last30Days' | 'last60Days') => {
183-
setDateFilter(filter);
184-
}, []);
185-
186198
const clearPreviewDocs = useCallback(() => {
187199
previewYDocRef.current.forEach((doc) => {
188200
doc.destroy();
@@ -315,20 +327,15 @@ export function DocumentHistoryModal({
315327
return (
316328
<Dialog
317329
open={open}
318-
onClose={() => onOpenChange(false)}
330+
onClose={handleClose}
319331
aria-labelledby={titleId}
320332
fullWidth
321333
maxWidth={false}
322334
keepMounted={false}
323335
disableAutoFocus={false}
324336
disableEnforceFocus={false}
325337
disableRestoreFocus
326-
PaperProps={{
327-
className: cn(
328-
'flex !h-full !w-full overflow-hidden rounded-2xl bg-surface-layer-02',
329-
'!max-h-[min(920px,_calc(100vh-160px))] !min-h-[min(689px,_calc(100vh-40px))] !min-w-[min(984px,_calc(100vw-40px))] !max-w-[min(1680px,_calc(100vw-240px))]'
330-
),
331-
}}
338+
PaperProps={DIALOG_PAPER_PROPS}
332339
>
333340
<DialogContent data-testid='version-history-modal' className='flex h-full w-full overflow-hidden p-0'>
334341
<div className='order-2 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-t-2xl md:order-1 md:rounded-l-2xl md:rounded-tr-none'>
@@ -342,6 +349,8 @@ export function DocumentHistoryModal({
342349
activeDoc={activeDoc}
343350
workspaceId={workspaceId}
344351
viewId={viewId}
352+
loadView={loadView}
353+
bindViewSync={bindViewSync}
345354
loadViewMeta={loadViewMeta}
346355
createRow={createRow}
347356
eventEmitter={eventEmitter}
@@ -358,7 +367,7 @@ export function DocumentHistoryModal({
358367
onSelect={setSelectedVersionId}
359368
dateFilter={dateFilter}
360369
onlyShowMine={onlyShowMine}
361-
onDateFilterChange={handleSetDateFilter}
370+
onDateFilterChange={setDateFilter}
362371
onOnlyShowMineChange={setOnlyShowMine}
363372
onRestoreClicked={handleRestore}
364373
isRestoring={isRestoring}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { expect } from '@jest/globals';
2+
import { render, waitFor } from '@testing-library/react';
3+
import * as Y from 'yjs';
4+
5+
import { DocumentHistoryModal } from '../DocumentHistoryModal';
6+
7+
/**
8+
* Regression test for the "embedded database infinitely loads in Version history" bug.
9+
*
10+
* Root cause: the version-history preview rendered an <Editor/> without forwarding
11+
* `loadView` / `bindViewSync` from the editor context. Embedded databases load their
12+
* own collab doc via `loadView`; when it was missing, `useDocumentLoader` bailed and
13+
* the database block spun forever.
14+
*
15+
* This test asserts those two functions are passed through to the preview <Editor/>.
16+
*/
17+
18+
// Capture the props the preview Editor is rendered with.
19+
let lastEditorProps: Record<string, unknown> | null = null;
20+
21+
jest.mock('@/components/editor', () => ({
22+
Editor: (props: Record<string, unknown>) => {
23+
lastEditorProps = props;
24+
return null;
25+
},
26+
}));
27+
28+
jest.mock('@/components/_shared/progress/ComponentLoading', () => () => null);
29+
jest.mock('../DocumentHistoryVersionList', () => ({ VersionList: () => null }));
30+
31+
jest.mock('react-i18next', () => ({
32+
useTranslation: () => ({ t: (key: string) => key }),
33+
}));
34+
35+
const loadView = jest.fn();
36+
const bindViewSync = jest.fn();
37+
const loadViewMeta = jest.fn();
38+
const createRow = jest.fn();
39+
const getViewIdFromDatabaseId = jest.fn();
40+
const getMentionUser = jest.fn();
41+
const loadDatabaseRelations = jest.fn();
42+
43+
const getCollabHistory = jest.fn();
44+
const previewCollabVersion = jest.fn();
45+
const revertCollabVersion = jest.fn();
46+
47+
jest.mock('@/components/app/app.hooks', () => ({
48+
useAppOperations: () => ({
49+
loadViewMeta,
50+
createRow,
51+
getViewIdFromDatabaseId,
52+
loadView,
53+
bindViewSync,
54+
}),
55+
useCollabHistory: () => ({ getCollabHistory, previewCollabVersion, revertCollabVersion }),
56+
useGetSubscriptions: () => jest.fn(),
57+
useCurrentWorkspaceId: () => 'workspace-1',
58+
useEventEmitter: () => ({ on: jest.fn(), off: jest.fn() }),
59+
useGetMentionUser: () => getMentionUser,
60+
useLoadDatabaseRelations: () => loadDatabaseRelations,
61+
}));
62+
63+
jest.mock('@/components/app/hooks/useSubscriptionPlan', () => ({
64+
useSubscriptionPlan: () => ({ isPro: false }),
65+
}));
66+
67+
jest.mock('@/components/main/app.hooks', () => ({
68+
useCurrentUser: () => ({ uid: '1' }),
69+
}));
70+
71+
describe('DocumentHistoryModal version preview', () => {
72+
beforeEach(() => {
73+
lastEditorProps = null;
74+
jest.clearAllMocks();
75+
76+
getCollabHistory.mockResolvedValue([
77+
{
78+
versionId: 'version-1',
79+
parentId: null,
80+
name: 'Version 1',
81+
createdAt: new Date('2026-05-21T09:38:06Z'),
82+
deletedAt: null,
83+
editors: [1],
84+
},
85+
]);
86+
previewCollabVersion.mockResolvedValue(new Y.Doc());
87+
});
88+
89+
it('forwards loadView and bindViewSync to the preview Editor so embedded databases can load', async () => {
90+
render(
91+
<DocumentHistoryModal
92+
open
93+
onOpenChange={jest.fn()}
94+
viewId="view-1"
95+
view={{ name: 'Project Tracker 2', icon: null }}
96+
/>
97+
);
98+
99+
await waitFor(() => {
100+
expect(lastEditorProps).not.toBeNull();
101+
});
102+
103+
// The regression: these were previously omitted, leaving embedded databases
104+
// stuck on an infinite loading spinner.
105+
expect(lastEditorProps?.loadView).toBe(loadView);
106+
expect(lastEditorProps?.bindViewSync).toBe(bindViewSync);
107+
108+
// The rest of the editor context should still be forwarded.
109+
expect(lastEditorProps?.loadViewMeta).toBe(loadViewMeta);
110+
expect(lastEditorProps?.createRow).toBe(createRow);
111+
expect(lastEditorProps?.getViewIdFromDatabaseId).toBe(getViewIdFromDatabaseId);
112+
expect(lastEditorProps?.loadDatabaseRelations).toBe(loadDatabaseRelations);
113+
expect(lastEditorProps?.readOnly).toBe(true);
114+
});
115+
});

0 commit comments

Comments
 (0)