From 78b2c2324ed146259d1d3102b0e925faebf50879 Mon Sep 17 00:00:00 2001 From: mdantonio Date: Wed, 30 Sep 2026 16:51:29 +0200 Subject: [PATCH 1/2] fix(editor): avoid shell reconstruction for existing row syncs --- .../collab/rowSyncProjection.test.ts | 91 +++++++++++++++++++ .../site/store/slices/site/collabBinding.ts | 9 +- .../site/store/slices/site/collabBranch.ts | 14 ++- 3 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/collab/rowSyncProjection.test.ts diff --git a/src/__tests__/collab/rowSyncProjection.test.ts b/src/__tests__/collab/rowSyncProjection.test.ts new file mode 100644 index 000000000..3d5d5f366 --- /dev/null +++ b/src/__tests__/collab/rowSyncProjection.test.ts @@ -0,0 +1,91 @@ +import { afterEach, expect, it } from 'bun:test' +import * as Y from 'yjs' +import { Awareness } from 'y-protocols/awareness' +import '@modules/base' +import { seedPageDoc, seedSiteDoc, MAIN_SITE_DOC_ID, encodeCollabDocId } from '@core/collab' +import { MAIN_BRANCH_ID } from '@core/branches' +import type { CollabProvider, BoundCollabDoc } from '@site/collab/collabProvider' +import { connectCollabProvider, disconnectCollabProvider } from '@site/store/slices/site/collabBinding' +import { useEditorStore } from '@site/store/store' +import { makePage, makeSite } from '../fixtures' + +function makeProvider(site: ReturnType) { + const presence = new Y.Doc() + const awareness = new Awareness(presence) + const entries = new Map() + const releases = new Map void>() + const provider: CollabProvider = { + bind(id) { + const known = entries.get(id) + if (known) return known + const doc = new Y.Doc() + if (id === MAIN_SITE_DOC_ID) seedSiteDoc(doc, site) + else { + const page = site.pages.find((p) => pageId(p.id) === id) + if (page) seedPageDoc(doc, page) + } + let release = () => {} + const whenSynced = new Promise((resolve) => { release = resolve }) + const entry = { doc, synced: false, whenSynced } + entries.set(id, entry) + releases.set(id, () => { entry.synced = true; release() }) + return entry + }, + unbind(id) { entries.get(id)?.doc.destroy(); entries.delete(id) }, + awareness, + status: () => 'connected', + canSend: () => true, + reconnectNow() {}, + onStatus: () => () => {}, + onReset: () => () => {}, + destroy() { + for (const entry of entries.values()) entry.doc.destroy() + awareness.destroy() + presence.destroy() + }, + } + return { provider, release: (id: string) => releases.get(id)?.() } +} + +function pageId(id: string) { + return encodeCollabDocId({ kind: 'page', branchId: MAIN_BRANCH_ID, rowId: id }) +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) + +afterEach(() => { + disconnectCollabProvider() + useEditorStore.getState().clearSite() +}) + +it('syncs existing pages without replacing the already projected shell', async () => { + const site = makeSite({ pages: [makePage(), makePage({ id: 'page-2', slug: 'second' })] }) + useEditorStore.setState({ site }) + const { provider, release } = makeProvider(site) + connectCollabProvider(provider) + release(MAIN_SITE_DOC_ID) + await settle() + const shell = useEditorStore.getState().site!.settings + const initial = useEditorStore.getState().site!.pages[0] + release(pageId('page-1')) + await settle() + expect(useEditorStore.getState().site!.pages[0]).not.toBe(initial) + expect(useEditorStore.getState().site!.settings).toBe(shell) + release(pageId('page-2')) + await settle() + expect(useEditorStore.getState().site!.settings).toBe(shell) +}) + +it('assembles a newly discovered peer page in roster order after its doc syncs', async () => { + const first = makePage() + const peer = makePage({ id: 'peer-page', slug: 'peer' }) + useEditorStore.setState({ site: makeSite({ pages: [first] }) }) + const { provider, release } = makeProvider(makeSite({ pages: [peer, first] })) + connectCollabProvider(provider) + release(MAIN_SITE_DOC_ID) + await settle() + expect(useEditorStore.getState().site!.pages.map((p) => p.id)).toEqual([first.id]) + release(pageId(peer.id)) + await settle() + expect(useEditorStore.getState().site!.pages.map((p) => p.id)).toEqual([peer.id, first.id]) +}) diff --git a/src/admin/pages/site/store/slices/site/collabBinding.ts b/src/admin/pages/site/store/slices/site/collabBinding.ts index 7aadd3a81..af17371f0 100644 --- a/src/admin/pages/site/store/slices/site/collabBinding.ts +++ b/src/admin/pages/site/store/slices/site/collabBinding.ts @@ -58,7 +58,7 @@ import { treeMap, type CollabDocSet, } from '@core/collab' -import { allDocIdsForSite, collabBranchId, notifyCollabBranchGone } from './collabBranch' +import { allDocIdsForSite, collabBranchId, needsSiteRosterAssembly, notifyCollabBranchGone } from './collabBranch' import { clonePackageJson } from '@core/site-dependencies/manifest' import { cloneSiteRuntimeConfig } from '@core/site-runtime' import { validateSite } from '@core/persistence/validate' @@ -620,10 +620,9 @@ function bindDocThroughProvider(docId: string): void { void binding.whenSynced.then(() => { gate.synced = true scheduleProjection(docId) - // A row doc bound on demand (a peer created the row) re-assembles the - // site once its content arrives — the roster projection skipped it - // while it was empty. - if (!isSiteDocId(docId)) scheduleProjection(siteDocId(collabBranchId())) + if (needsSiteRosterAssembly(storeApi?.getState().site, docId)) { + scheduleProjection(siteDocId(collabBranchId())) + } }) } diff --git a/src/admin/pages/site/store/slices/site/collabBranch.ts b/src/admin/pages/site/store/slices/site/collabBranch.ts index 538eaebc3..4507afe62 100644 --- a/src/admin/pages/site/store/slices/site/collabBranch.ts +++ b/src/admin/pages/site/store/slices/site/collabBranch.ts @@ -7,7 +7,7 @@ * loads; detached-mode tests run on main. */ import { MAIN_BRANCH_ID } from '@core/branches' -import { encodeCollabDocId, siteDocId } from '@core/collab' +import { encodeCollabDocId, parseCollabDocId, siteDocId } from '@core/collab' import type { SiteDocument } from '@core/page-tree' let activeBranchId: string = MAIN_BRANCH_ID @@ -45,3 +45,15 @@ export function allDocIdsForSite(site: SiteDocument): string[] { ...site.layouts.map((l) => encodeCollabDocId({ kind: 'layout', branchId, rowId: l.id })), ] } + +/** + * A new peer row needs roster assembly; an existing row projects independently. + * Revalidating the whole shell for every sync scales with rows × shell size. + */ +export function needsSiteRosterAssembly(site: SiteDocument | null | undefined, docId: string): boolean { + const parsed = parseCollabDocId(docId) + if (!parsed || parsed.kind === 'site' || !site) return false + const rows = parsed.kind === 'page' ? site.pages + : parsed.kind === 'component' ? site.visualComponents : site.layouts + return !rows.some((row) => row.id === parsed.rowId) +} From 2e71cfad1071b5b6ae5fa365616e8fbfebdf656e Mon Sep 17 00:00:00 2001 From: mdantonio Date: Wed, 30 Sep 2026 17:25:25 +0200 Subject: [PATCH 2/2] test(editor): await collaborative projections by state --- .../collab/rowSyncProjection.test.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/__tests__/collab/rowSyncProjection.test.ts b/src/__tests__/collab/rowSyncProjection.test.ts index 3d5d5f366..fc2fba5bd 100644 --- a/src/__tests__/collab/rowSyncProjection.test.ts +++ b/src/__tests__/collab/rowSyncProjection.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, it } from 'bun:test' +import { waitFor } from '@testing-library/react' import * as Y from 'yjs' import { Awareness } from 'y-protocols/awareness' import '@modules/base' @@ -51,8 +52,6 @@ function pageId(id: string) { return encodeCollabDocId({ kind: 'page', branchId: MAIN_BRANCH_ID, rowId: id }) } -const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) - afterEach(() => { disconnectCollabProvider() useEditorStore.getState().clearSite() @@ -64,28 +63,28 @@ it('syncs existing pages without replacing the already projected shell', async ( const { provider, release } = makeProvider(site) connectCollabProvider(provider) release(MAIN_SITE_DOC_ID) - await settle() + await waitFor(() => expect(useEditorStore.getState().site!.settings).not.toBe(site.settings)) const shell = useEditorStore.getState().site!.settings const initial = useEditorStore.getState().site!.pages[0] release(pageId('page-1')) - await settle() - expect(useEditorStore.getState().site!.pages[0]).not.toBe(initial) + await waitFor(() => expect(useEditorStore.getState().site!.pages[0]).not.toBe(initial)) expect(useEditorStore.getState().site!.settings).toBe(shell) + const second = useEditorStore.getState().site!.pages[1] release(pageId('page-2')) - await settle() + await waitFor(() => expect(useEditorStore.getState().site!.pages[1]).not.toBe(second)) expect(useEditorStore.getState().site!.settings).toBe(shell) }) it('assembles a newly discovered peer page in roster order after its doc syncs', async () => { const first = makePage() const peer = makePage({ id: 'peer-page', slug: 'peer' }) - useEditorStore.setState({ site: makeSite({ pages: [first] }) }) + const initialSite = makeSite({ pages: [first] }) + useEditorStore.setState({ site: initialSite }) const { provider, release } = makeProvider(makeSite({ pages: [peer, first] })) connectCollabProvider(provider) release(MAIN_SITE_DOC_ID) - await settle() + await waitFor(() => expect(useEditorStore.getState().site!.settings).not.toBe(initialSite.settings)) expect(useEditorStore.getState().site!.pages.map((p) => p.id)).toEqual([first.id]) release(pageId(peer.id)) - await settle() - expect(useEditorStore.getState().site!.pages.map((p) => p.id)).toEqual([peer.id, first.id]) + await waitFor(() => expect(useEditorStore.getState().site!.pages.map((p) => p.id)).toEqual([peer.id, first.id])) })