🐛(frontend) fix broadcast store sync

When going from one subdoc to another by example,
the broadcast store could have difficulty to resync.
This commit ensures that the broadcast store
cleans up and resets its state when rerendering.
It will stop as well triggering the action for
the current user avoiding potential unecessary
requests.
This commit is contained in:
Anthony LC
2026-01-30 10:20:43 +01:00
parent 709076067b
commit 44b38347c4
4 changed files with 100 additions and 24 deletions

View File

@@ -6,6 +6,10 @@ and this project adheres to
## [Unreleased]
### Fixed
🐛(frontend) fix broadcast store sync #1846
## [v4.5.0] - 2026-01-28
### Added

View File

@@ -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 }) => {
@@ -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 ({

View File

@@ -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(() => {
@@ -33,8 +33,9 @@ export const useCollaboration = (room?: string, initialContent?: Base64) => {
useEffect(() => {
return () => {
if (room) {
cleanupBroadcast();
destroyProvider();
}
};
}, [destroyProvider, room]);
}, [destroyProvider, room, cleanupBroadcast]);
};

View File

@@ -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: (
@@ -15,11 +17,12 @@ 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>;
};
};
}
@@ -27,7 +30,22 @@ interface BroadcastState {
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) {
@@ -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();
}
};
@@ -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);
});
},
}));