diff --git a/src/__tests__/collab/rowSyncProjection.test.ts b/src/__tests__/collab/rowSyncProjection.test.ts new file mode 100644 index 000000000..fc2fba5bd --- /dev/null +++ b/src/__tests__/collab/rowSyncProjection.test.ts @@ -0,0 +1,90 @@ +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' +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 }) +} + +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 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 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 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' }) + 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 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 waitFor(() => 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) +}