Skip to content

Commit 27c042c

Browse files
authored
Sync and duplicate (#242)
* chore: sync before duplicate content * chore: improve duplicated content sync
1 parent 3ff0728 commit 27c042c

File tree

12 files changed

+2029
-17
lines changed

12 files changed

+2029
-17
lines changed

cypress/e2e/page/duplicate-page.cy.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,17 +96,13 @@ describe('Duplicate Page', () => {
9696
});
9797
testLog.info('Clicked Duplicate');
9898

99-
// Verify toast appears (indicates duplication started)
100-
cy.get('[data-sonner-toast]', { timeout: 5000 }).should('exist');
101-
testLog.info('Toast notification appeared');
99+
// Verify blocking loader appears (indicates duplication started and blocks interaction)
100+
cy.get('[data-testid="blocking-loader"]', { timeout: 5000 }).should('exist');
101+
testLog.info('Blocking loader appeared');
102102

103-
// Wait for duplication to complete
104-
// Don't wait for toast to disappear as it may persist - just wait for operation
105-
waitForReactUpdate(3000);
106-
testLog.info('Duplication operation completed');
107-
108-
// Verify no error toast appeared
109-
cy.get('[data-sonner-toast][data-type="error"]').should('not.exist');
103+
// Wait for duplication to complete and blocking loader to be dismissed
104+
cy.get('[data-testid="blocking-loader"]', { timeout: 30000 }).should('not.exist');
105+
testLog.info('Blocking loader dismissed - duplication completed');
110106

111107
// Step 7: Find and open the duplicated document
112108
testLog.step(7, 'Opening the duplicated document');

src/application/services/js-services/http/http_api.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,81 @@ export async function updateCollab(
738738
return context;
739739
}
740740

741+
/**
742+
* Batch sync multiple collab documents to the server.
743+
* This is the same API that desktop uses before duplicating to ensure
744+
* the server has the latest state of all documents.
745+
*
746+
* @param workspaceId - The workspace ID
747+
* @param items - Array of collab items to sync, each containing objectId, collabType, stateVector, and docState
748+
* @returns The batch sync response containing results for each collab
749+
*/
750+
export async function collabFullSyncBatch(
751+
workspaceId: string,
752+
items: Array<{
753+
objectId: string;
754+
collabType: Types;
755+
stateVector: Uint8Array;
756+
docState: Uint8Array;
757+
}>
758+
): Promise<void> {
759+
const url = `/api/workspace/v1/${workspaceId}/collab/full-sync/batch`;
760+
761+
// Import the collab proto types
762+
const { collab } = await import('@/proto/messages');
763+
764+
// Build the protobuf request
765+
const request = collab.CollabBatchSyncRequest.create({
766+
items: items.map((item) => ({
767+
objectId: item.objectId,
768+
collabType: item.collabType,
769+
compression: collab.PayloadCompressionType.COMPRESSION_NONE,
770+
sv: item.stateVector,
771+
docState: item.docState,
772+
})),
773+
responseCompression: collab.PayloadCompressionType.COMPRESSION_NONE,
774+
});
775+
776+
// Encode the request to binary
777+
const encoded = collab.CollabBatchSyncRequest.encode(request).finish();
778+
779+
let deviceId = localStorage.getItem('x-device-id');
780+
781+
if (!deviceId) {
782+
deviceId = nanoid(8);
783+
localStorage.setItem('x-device-id', deviceId);
784+
}
785+
786+
// Send the request with protobuf content type
787+
const response = await axiosInstance?.post(url, encoded, {
788+
headers: {
789+
'Content-Type': 'application/octet-stream',
790+
'client-version': 'web',
791+
'device-id': deviceId,
792+
},
793+
responseType: 'arraybuffer',
794+
});
795+
796+
if (!response || response.status !== 200) {
797+
throw new Error(`Failed to sync collabs: ${response?.status}`);
798+
}
799+
800+
// Decode and check the response for errors
801+
const responseData = new Uint8Array(response.data);
802+
const batchResponse = collab.CollabBatchSyncResponse.decode(responseData);
803+
804+
// Check for any errors in the results
805+
for (const result of batchResponse.results) {
806+
if (result.error) {
807+
Log.warn('Collab sync error', {
808+
objectId: result.objectId,
809+
collabType: result.collabType,
810+
error: result.error,
811+
});
812+
}
813+
}
814+
}
815+
741816
export async function getCollab(workspaceId: string, objectId: string, collabType: Types) {
742817
const url = `/api/workspace/v1/${workspaceId}/collab/${objectId}`;
743818

src/application/services/js-services/sync-protocol.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ export interface SyncContext {
2020
* Emit function to send messages back to the server.
2121
*/
2222
emit: (reply: messages.IMessage) => void;
23+
/**
24+
* Flush function to immediately send any pending updates.
25+
* This is used before operations like duplicate to ensure all local changes
26+
* are synced to the server.
27+
*/
28+
flush?: () => void;
2329
}
2430

2531
interface AwarenessEvent {
@@ -127,6 +133,7 @@ export const initSync = (ctx: SyncContext) => {
127133
let onAwarenessChange;
128134
const updates: Uint8Array[] = [];
129135
const debounced = debounce(() => {
136+
if (updates.length === 0) return; // Skip if no pending updates
130137
const mergedUpdates = Y.mergeUpdates(updates);
131138

132139
updates.length = 0; // Clear the updates array without GC overhead
@@ -141,6 +148,12 @@ export const initSync = (ctx: SyncContext) => {
141148
},
142149
});
143150
}, 250);
151+
152+
// Store flush function in context for external access
153+
ctx.flush = () => {
154+
debounced.flush();
155+
};
156+
144157
const onUpdate = (update: Uint8Array, origin: string) => {
145158
if (origin === 'remote') {
146159
return; // Ignore remote updates

src/components/app/app-overlay/AppOverlayContext.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ export const AppOverlayContext = createContext<{
66
openManageSpaceModal: (viewId: string) => void;
77
openCreateSpaceModal: () => void;
88
openDeleteSpaceModal: (viewId: string) => void;
9+
/**
10+
* Show a blocking loading overlay that prevents user interaction.
11+
* Used during operations like duplicate to prevent opening incomplete pages.
12+
*/
13+
showBlockingLoader: (message?: string) => void;
14+
/**
15+
* Hide the blocking loading overlay.
16+
*/
17+
hideBlockingLoader: () => void;
918
}>({
1019
openRenameModal: () => {
1120
//
@@ -22,6 +31,12 @@ export const AppOverlayContext = createContext<{
2231
openDeleteSpaceModal: () => {
2332
//
2433
},
34+
showBlockingLoader: () => {
35+
//
36+
},
37+
hideBlockingLoader: () => {
38+
//
39+
},
2540
});
2641

2742
export function useAppOverlayContext () {

src/components/app/app-overlay/AppOverlayProvider.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import React, { useMemo, useState } from 'react';
1+
import React, { useCallback, useMemo, useState } from 'react';
22

3+
import LoadingDots from '@/components/_shared/LoadingDots';
34
import { findView } from '@/components/_shared/outline/utils';
45
import { AppOverlayContext } from '@/components/app/app-overlay/AppOverlayContext';
56
import { useAppHandlers, useAppOutline } from '@/components/app/app.hooks';
@@ -19,7 +20,16 @@ export function AppOverlayProvider ({
1920
const [manageSpaceId, setManageSpaceId] = useState<string | null>(null);
2021
const [createSpaceModalOpen, setCreateSpaceModalOpen] = useState(false);
2122
const [deleteSpaceId, setDeleteSpaceId] = useState<string | null>(null);
23+
const [blockingLoaderMessage, setBlockingLoaderMessage] = useState<string | null>(null);
2224
const { updatePage } = useAppHandlers();
25+
26+
const showBlockingLoader = useCallback((message?: string) => {
27+
setBlockingLoaderMessage(message || 'Loading...');
28+
}, []);
29+
30+
const hideBlockingLoader = useCallback(() => {
31+
setBlockingLoaderMessage(null);
32+
}, []);
2333
const outline = useAppOutline();
2434
const renameView = useMemo(() => {
2535
if (!renameViewId) return null;
@@ -38,6 +48,8 @@ export function AppOverlayProvider ({
3848
setCreateSpaceModalOpen(true);
3949
},
4050
openDeleteSpaceModal: setDeleteSpaceId,
51+
showBlockingLoader,
52+
hideBlockingLoader,
4153
}}
4254
>
4355
{children}
@@ -80,6 +92,18 @@ export function AppOverlayProvider ({
8092
}
8193
}
8294
/>}
95+
{/* Blocking loader overlay - prevents user interaction during operations like duplicate */}
96+
{blockingLoaderMessage && (
97+
<div
98+
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50"
99+
data-testid="blocking-loader"
100+
>
101+
<div className="flex flex-col items-center gap-4 rounded-lg bg-bg-body p-6 shadow-lg">
102+
<LoadingDots />
103+
<span className="text-text-title">{blockingLoaderMessage}</span>
104+
</div>
105+
</div>
106+
)}
83107
</AppOverlayContext.Provider>
84108
);
85109
}

src/components/app/contexts/SyncInternalContext.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ export interface SyncInternalContextType {
2222
eventEmitter: EventEmitter;
2323
awarenessMap: Record<string, Awareness>;
2424
lastUpdatedCollab: UpdateCollabInfo | null;
25+
/**
26+
* Flush all pending updates for all registered sync contexts.
27+
* This ensures all local changes are sent to the server before operations like duplicate.
28+
*/
29+
flushAllSync: () => void;
30+
/**
31+
* Sync all registered collab documents to the server via HTTP API.
32+
* This is similar to desktop's collab_full_sync_batch - it sends the full doc state
33+
* to ensure the server has the latest data before operations like duplicate.
34+
*/
35+
syncAllToServer: (workspaceId: string) => Promise<void>;
2536
}
2637

2738
export const SyncInternalContext = createContext<SyncInternalContextType | null>(null);

src/components/app/header/MoreActionsContent.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ReactComponent as MoveToIcon } from '@/assets/icons/move_to.svg';
1010
import { findView } from '@/components/_shared/outline/utils';
1111
import { useAppOverlayContext } from '@/components/app/app-overlay/AppOverlayContext';
1212
import { useAppHandlers, useAppOutline, useAppView, useCurrentWorkspaceId } from '@/components/app/app.hooks';
13+
import { useSyncInternal } from '@/components/app/contexts/SyncInternalContext';
1314
import MovePagePopover from '@/components/app/view-actions/MovePagePopover';
1415
import { useService } from '@/components/main/app.hooks';
1516
import { DropdownMenuGroup, DropdownMenuItem } from '@/components/ui/dropdown-menu';
@@ -23,6 +24,8 @@ function MoreActionsContent({ itemClicked, viewId }: {
2324
const { t } = useTranslation();
2425
const {
2526
openDeleteModal,
27+
showBlockingLoader,
28+
hideBlockingLoader,
2629
} = useAppOverlayContext();
2730
const service = useService();
2831
const workspaceId = useCurrentWorkspaceId();
@@ -40,20 +43,28 @@ function MoreActionsContent({ itemClicked, viewId }: {
4043
const {
4144
refreshOutline,
4245
} = useAppHandlers();
46+
const { syncAllToServer } = useSyncInternal();
4347
const handleDuplicateClick = useCallback(async () => {
4448
if (!workspaceId || !service) return;
4549
itemClicked?.();
46-
toast.loading(`${t('moreAction.duplicateView')}...`);
50+
// Show blocking loader to prevent user from interacting with the UI
51+
// (e.g., clicking on the duplicated page before it's fully created)
52+
showBlockingLoader(`${t('moreAction.duplicateView')}...`);
4753
try {
54+
// Sync all collab documents to the server via HTTP API before duplicating
55+
// This is similar to desktop's collab_full_sync_batch - ensures the server
56+
// has the latest data before the duplicate operation
57+
await syncAllToServer(workspaceId);
4858
await service.duplicateAppPage(workspaceId, viewId);
4959
void refreshOutline?.();
5060
itemClicked?.();
5161
// eslint-disable-next-line
5262
} catch (e: any) {
53-
toast.dismiss();
5463
toast.error(e.message);
64+
} finally {
65+
hideBlockingLoader();
5566
}
56-
}, [workspaceId, service, viewId, refreshOutline, itemClicked, t]);
67+
}, [workspaceId, service, viewId, refreshOutline, itemClicked, t, syncAllToServer, showBlockingLoader, hideBlockingLoader]);
5768

5869
const [container, setContainer] = useState<HTMLElement | null>(null);
5970
const containerRef = useCallback((el: HTMLElement | null) => {

src/components/app/layers/AppSyncLayer.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export const AppSyncLayer: React.FC<AppSyncLayerProps> = ({ children }) => {
4949
const broadcastChannel = useBroadcastChannel(`workspace:${currentWorkspaceId!}`);
5050

5151
// Initialize sync context for collaborative editing
52-
const { registerSyncContext, lastUpdatedCollab } = useSync(webSocket, broadcastChannel, eventEmitterRef.current);
52+
const { registerSyncContext, lastUpdatedCollab, flushAllSync, syncAllToServer } = useSync(webSocket, broadcastChannel, eventEmitterRef.current);
5353

5454
// Handle WebSocket reconnection
5555
const reconnectWebSocket = useCallback(() => {
@@ -294,8 +294,10 @@ export const AppSyncLayer: React.FC<AppSyncLayerProps> = ({ children }) => {
294294
eventEmitter: eventEmitterRef.current,
295295
awarenessMap,
296296
lastUpdatedCollab,
297+
flushAllSync,
298+
syncAllToServer,
297299
}),
298-
[webSocket, broadcastChannel, registerSyncContext, awarenessMap, lastUpdatedCollab]
300+
[webSocket, broadcastChannel, registerSyncContext, awarenessMap, lastUpdatedCollab, flushAllSync, syncAllToServer]
299301
);
300302

301303
return <SyncInternalContext.Provider value={syncContextValue}>{children}</SyncInternalContext.Provider>;

0 commit comments

Comments
 (0)