diff --git a/packages/common/infra/src/blocksuite/migration/fixing.ts b/packages/common/infra/src/blocksuite/migration/fixing.ts index 5813da65da..840cb5a623 100644 --- a/packages/common/infra/src/blocksuite/migration/fixing.ts +++ b/packages/common/infra/src/blocksuite/migration/fixing.ts @@ -13,6 +13,9 @@ export function fixWorkspaceVersion(rootDoc: YDoc) { * Blocksuite just set the value, do nothing else. */ function doFix() { + if (meta.size === 0) { + return; + } const workspaceVersion = meta.get('workspaceVersion'); if (typeof workspaceVersion !== 'number' || workspaceVersion < 2) { transact( diff --git a/packages/common/infra/src/workspace/engine/sync/engine.ts b/packages/common/infra/src/workspace/engine/sync/engine.ts index 375682889f..d2a62cec4b 100644 --- a/packages/common/infra/src/workspace/engine/sync/engine.ts +++ b/packages/common/infra/src/workspace/engine/sync/engine.ts @@ -68,15 +68,17 @@ export class SyncEngine { this.onStatusChange.emit(s); } isRootDocLoaded = LiveData.from( - new Observable(observer => { + new Observable(observer => { observer.next( - this.status.local - ? this.status.local.step > SyncPeerStep.LoadingRootDoc - : false + [this.status?.local, ...(this.status?.remotes ?? [])].some( + p => p?.rootDocLoaded === true + ) ); this.onStatusChange.on(status => { observer.next( - status.local ? status.local.step > SyncPeerStep.LoadingRootDoc : false + [status?.local, ...(status?.remotes ?? [])].some( + p => p?.rootDocLoaded === true + ) ); }); }), diff --git a/packages/common/infra/src/workspace/engine/sync/peer.ts b/packages/common/infra/src/workspace/engine/sync/peer.ts index 5a0900ac51..f7c9673c60 100644 --- a/packages/common/infra/src/workspace/engine/sync/peer.ts +++ b/packages/common/infra/src/workspace/engine/sync/peer.ts @@ -20,6 +20,7 @@ export interface SyncPeerStatus { pendingPullUpdates: number; pendingPushUpdates: number; lastError: string | null; + rootDocLoaded: boolean; } /** @@ -56,6 +57,7 @@ export class SyncPeer { pendingPullUpdates: 0, pendingPushUpdates: 0, lastError: null, + rootDocLoaded: false, }; onStatusChange = new Slot(); readonly abort = new AbortController(); @@ -122,6 +124,7 @@ export class SyncPeer { pendingPullUpdates: 0, pendingPushUpdates: 0, lastError: 'Retrying sync after 5 seconds', + rootDocLoaded: this.status.rootDocLoaded, }; await Promise.race([ new Promise(resolve => { @@ -295,6 +298,13 @@ export class SyncPeer { (await this.storage.pull(doc.guid, encodeStateVector(doc))) ?? {}; throwIfAborted(abort); + if (docData !== undefined && doc.guid === this.rootDoc.guid) { + this.status = { + ...this.status, + rootDocLoaded: true, + }; + } + if (docData) { applyUpdate(doc, docData, 'load'); } @@ -400,6 +410,7 @@ export class SyncPeer { pendingPushUpdates: this.state.pushUpdatesQueue.length + (this.state.pushingUpdate ? 1 : 0), lastError, + rootDocLoaded: this.status.rootDocLoaded, }; } diff --git a/packages/frontend/workspace-impl/src/cloud/sync.ts b/packages/frontend/workspace-impl/src/cloud/sync.ts index 553bd8174b..1ce7349640 100644 --- a/packages/frontend/workspace-impl/src/cloud/sync.ts +++ b/packages/frontend/workspace-impl/src/cloud/sync.ts @@ -12,6 +12,8 @@ import { base64ToUint8Array, uint8ArrayToBase64 } from '../utils/base64'; const logger = new DebugLogger('affine:storage:socketio'); +(window as any)._TEST_SIMULATE_SYNC_LAG = Promise.resolve(); + export class AffineSyncStorage implements SyncStorage { name = 'affine-cloud'; @@ -57,6 +59,9 @@ export class AffineSyncStorage implements SyncStorage { docId: string, state: Uint8Array ): Promise<{ data: Uint8Array; state?: Uint8Array } | null> { + // for testing + await (window as any)._TEST_SIMULATE_SYNC_LAG; + const stateVector = state ? await uint8ArrayToBase64(state) : undefined; logger.debug('doc-load-v2', { diff --git a/packages/frontend/workspace-impl/src/local/sync-sqlite.ts b/packages/frontend/workspace-impl/src/local/sync-sqlite.ts index 5372ca9439..17a2a777f3 100644 --- a/packages/frontend/workspace-impl/src/local/sync-sqlite.ts +++ b/packages/frontend/workspace-impl/src/local/sync-sqlite.ts @@ -20,6 +20,13 @@ export class SQLiteSyncStorage implements SyncStorage { ); if (update) { + if ( + update.byteLength === 0 || + (update.byteLength === 2 && update[0] === 0 && update[1] === 0) + ) { + return null; + } + return { data: update, state: encodeStateVectorFromUpdate(update), diff --git a/tests/affine-cloud/e2e/collaboration.spec.ts b/tests/affine-cloud/e2e/collaboration.spec.ts index 3ad004675a..016fb55441 100644 --- a/tests/affine-cloud/e2e/collaboration.spec.ts +++ b/tests/affine-cloud/e2e/collaboration.spec.ts @@ -15,7 +15,10 @@ import { waitForEditorLoad, } from '@affine-test/kit/utils/page-logic'; import { clickUserInfoCard } from '@affine-test/kit/utils/setting'; -import { clickSideBarSettingButton } from '@affine-test/kit/utils/sidebar'; +import { + clickSideBarCurrentWorkspaceBanner, + clickSideBarSettingButton, +} from '@affine-test/kit/utils/sidebar'; import { createLocalWorkspace } from '@affine-test/kit/utils/workspace'; import { expect } from '@playwright/test'; @@ -294,3 +297,72 @@ test('can sync svg between different browsers', async ({ page, browser }) => { expect(svg2).toEqual(svg1); } }); + +test('When the first sync is not completed, should always show loading', async ({ + page, + browser, +}) => { + await page.reload(); + await waitForEditorLoad(page); + await createLocalWorkspace( + { + name: 'test', + }, + page + ); + await enableCloudWorkspace(page); + await clickNewPageButton(page); + await waitForEditorLoad(page); + const title = getBlockSuiteEditorTitle(page); + await title.pressSequentially('TEST TITLE', { + delay: 50, + }); + + const context = await browser.newContext(); + await skipOnboarding(context); + const page2 = await context.newPage(); + await loginUser(page2, user.email); + + // simulate sync stuck + await page2.evaluate(() => { + (window as any)._TEST_SIMULATE_SYNC_LAG = new Promise(() => {}); + }); + const localWorkspaceUrl = page2.url(); + await clickSideBarCurrentWorkspaceBanner(page2); + await page2.getByTestId('workspace-card').getByText('test').click(); // enter "test" workspace + + await page2.waitForTimeout(1000); + + await expect( + page2.getByTestId('page-list-item').getByText('TEST TITLE') + ).not.toBeVisible(); // should be loading + + // Simulate user refresh and re-enter workspace, should still be loading + await page2.goto(localWorkspaceUrl); + + // setup sync lag + await page2.evaluate(() => { + (window as any).resolveSyncLag = null; + (window as any)._TEST_SIMULATE_SYNC_LAG = new Promise(resolve => { + (window as any).resolveSyncLag = resolve; + }); + }); + await clickSideBarCurrentWorkspaceBanner(page2); + await page2.getByTestId('workspace-card').getByText('test').click(); // enter "test" workspace + + await page2.waitForTimeout(1000); + + await expect( + page2.getByTestId('page-list-item').getByText('TEST TITLE') + ).not.toBeVisible(); // should be loading + + await page2.evaluate(() => { + (window as any).resolveSyncLag(); + }); // start syncing + await page2.getByTestId('page-list-item').getByText('TEST TITLE').click(); // should be able to click page + await waitForEditorLoad(page2); + + expect(await getBlockSuiteEditorTitle(page2).innerText()).toContain( + 'TEST TITLE' + ); +});