Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ and this project adheres to

## [Unreleased]

### Fixed

🐛(frontend) fix broadcast store sync #1846

## [v4.5.0] - 2026-01-28

### Added
Expand Down
60 changes: 53 additions & 7 deletions src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import {
mockedDocument,
verifyDocName,
} from './utils-common';
import { mockedAccesses, mockedInvitations } from './utils-share';
import {
connectOtherUserToDoc,
mockedAccesses,
mockedInvitations,
updateShareLink,
} from './utils-share';
import { createRootSubPage, getTreeRow } from './utils-sub-pages';

test.beforeEach(async ({ page }) => {
Expand Down Expand Up @@ -52,13 +57,54 @@ test.describe('Doc Header', () => {
).toBeVisible();
});

test('it updates the title doc', async ({ page, browserName }) => {
await createDoc(page, 'doc-update', browserName, 1);
const docTitle = page.getByRole('textbox', { name: 'Document title' });
await expect(docTitle).toBeVisible();
await docTitle.fill('Hello World');
await docTitle.blur();
test('it updates the title doc and check the broadcast', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(
page,
'doc-title-update',
browserName,
1,
);
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Editing');

const docUrl = page.url();

const { otherPage, cleanup } = await connectOtherUserToDoc({
docUrl,
browserName,
withoutSignIn: true,
docTitle,
});

// Wait for other page to sync
await page.waitForTimeout(1000);

await page.keyboard.press('Escape');
const elTitle = page.getByRole('textbox', { name: 'Document title' });
await expect(elTitle).toBeVisible();
await elTitle.fill('Hello World');
await elTitle.blur();
await verifyDocName(page, 'Hello World');

// Wait for other page to sync
await page.waitForTimeout(1000);

// Check other user page
await verifyDocName(otherPage, 'Hello World');

const elTitleOther = otherPage.getByRole('textbox', {
name: 'Document title',
});
await elTitleOther.fill('Hello Other World');
await elTitleOther.blur();

// Check first user page
await verifyDocName(page, 'Hello Other World');

await cleanup();
});

test('it updates the title doc adding a leading emoji', async ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Base64 } from '../types';

export const useCollaboration = (room?: string, initialContent?: Base64) => {
const collaborationUrl = useCollaborationUrl(room);
const { setBroadcastProvider } = useBroadcastStore();
const { setBroadcastProvider, cleanupBroadcast } = useBroadcastStore();
const { provider, createProvider, destroyProvider } = useProviderStore();

useEffect(() => {
Expand All @@ -33,8 +33,9 @@ export const useCollaboration = (room?: string, initialContent?: Base64) => {
useEffect(() => {
return () => {
if (room) {
cleanupBroadcast();
destroyProvider();
}
};
}, [destroyProvider, room]);
}, [destroyProvider, room, cleanupBroadcast]);
};
55 changes: 40 additions & 15 deletions src/frontend/apps/impress/src/stores/useBroadcastStore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { create } from 'zustand';
interface BroadcastState {
addTask: (taskLabel: string, action: () => void) => void;
broadcast: (taskLabel: string) => void;
cleanupBroadcast: () => void;
getBroadcastProvider: () => HocuspocusProvider | undefined;
handleProviderSync: () => void;
provider?: HocuspocusProvider;
setBroadcastProvider: (provider: HocuspocusProvider) => void;
setTask: (
Expand All @@ -15,19 +17,35 @@ interface BroadcastState {
) => void;
tasks: {
[taskLabel: string]: {
task: Y.Array<string>;
action: () => void;
observer: (
event: Y.YArrayEvent<string>,
transaction: Y.Transaction,
) => void;
task: Y.Array<string>;
};
};
}

export const useBroadcastStore = create<BroadcastState>((set, get) => ({
provider: undefined,
tasks: {},
setBroadcastProvider: (provider) => set({ provider }),
setBroadcastProvider: (provider) => {
// Clean up old provider listeners
const oldProvider = get().provider;
if (oldProvider) {
oldProvider.off('synced', get().handleProviderSync);
}

provider.on('synced', get().handleProviderSync);
set({ provider });
},
handleProviderSync: () => {
const tasks = get().tasks;
Object.entries(tasks).forEach(([taskLabel, { action }]) => {
get().addTask(taskLabel, action);
});
},
getBroadcastProvider: () => {
const provider = get().provider;
if (!provider) {
Expand All @@ -43,20 +61,16 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({
return;
}

const existingTask = get().tasks[taskLabel];
if (existingTask) {
existingTask.task.unobserve(existingTask.observer);
get().setTask(taskLabel, existingTask.task, action);
return;
}

const task = provider.document.getArray<string>(taskLabel);
get().setTask(taskLabel, task, action);
},
setTask: (taskLabel: string, task: Y.Array<string>, action: () => void) => {
let isInitializing = true;
const observer = () => {
if (!isInitializing) {
const observer = (
_event: Y.YArrayEvent<string>,
transaction: Y.Transaction,
) => {
if (!isInitializing && !transaction.local) {
action();
}
};
Expand All @@ -73,16 +87,27 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({
[taskLabel]: {
task,
observer,
action,
},
},
}));
},
broadcast: (taskLabel) => {
// Broadcast via Y.js provider (for users on the same document)
const obTask = get().tasks?.[taskLabel];
if (!obTask || !obTask.task) {
console.warn(`Task ${taskLabel} is not defined`);
return;
if (obTask?.task) {
obTask.task.push([`broadcast: ${taskLabel}`]);
}
obTask.task.push([`broadcast: ${taskLabel}`]);
},
cleanupBroadcast: () => {
const provider = get().provider;
if (provider) {
provider.off('synced', get().handleProviderSync);
}

// Unobserve all document-specific tasks
Object.values(get().tasks).forEach(({ task, observer }) => {
task.unobserve(observer);
});
},
}));
Loading