diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index c0bcc632c..e991d4858 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -611,6 +611,10 @@ exponential backoff, and each (re)connect re-runs syncStep1 so Yjs state vectors pull exactly the missed delta. `usePersistence` HTTP-loads the document once for first paint, then connects the provider — edits gate on each doc's first sync so an unseeded doc can never receive local ops. +Before connection, `detachedCollabDocs.ts` retains row JSON and creates each +local Y doc on first access. Seeds precede undo tracking; unused row seeds +remain available for roster undo after deletion. Connecting replaces these +local docs with server-seeded provider documents. Because every committed edit is a frame, burst-prone inputs coalesce before they commit: the `ColorInput` primitive throttles picker-drag change events (leading fire for instant clicks, one trailing fire with the final value), diff --git a/src/__tests__/collab/detachedDocs.test.ts b/src/__tests__/collab/detachedDocs.test.ts new file mode 100644 index 000000000..3e358a8cc --- /dev/null +++ b/src/__tests__/collab/detachedDocs.test.ts @@ -0,0 +1,52 @@ +import { afterEach, expect, it } from 'bun:test' +import '@modules/base' +import { encodeCollabDocId, projectPageDoc } from '@core/collab' +import { createDetachedCollabDocSet } from '@site/store/slices/site/detachedCollabDocs' +import { useEditorStore } from '@site/store/store' +import { makePage, makeSite } from '../fixtures' + +function pageId(id: string) { + return encodeCollabDocId({ kind: 'page', branchId: 'main', rowId: id }) +} + +afterEach(() => useEditorStore.getState().clearSite()) + +it('allocates only the requested row and seeds it once', () => { + const pages = Array.from({ length: 100 }, (_, i) => makePage({ id: `page-${i}` })) + const created: string[] = [] + const docs = createDetachedCollabDocSet(makeSite({ pages }), 'main', (id) => { created.push(id) }) + expect([...docs.entries()]).toHaveLength(0) + const doc = docs.get(pageId('page-42'))! + expect(projectPageDoc(doc, 'page-42')).toEqual(pages[42]) + expect(docs.ensure(pageId('page-42'))).toBe(doc) + expect(created).toEqual([pageId('page-42')]) + expect([...docs.entries()]).toHaveLength(1) + expect(docs.get(pageId('missing'))).toBeUndefined() + docs.delete(pageId('page-43')) + expect(docs.get(pageId('page-43'))).toBeUndefined() + docs.delete(pageId('page-42')) +}) + +it('keeps initial content available when the first edit is undone and redone', () => { + const page = makePage() + useEditorStore.getState().loadSite(makeSite({ pages: [page] })) + useEditorStore.getState().renameNode(page.rootNodeId, 'Renamed root') + expect(useEditorStore.getState().site!.pages[0].nodes.root.label).toBe('Renamed root') + useEditorStore.getState().undo() + expect(useEditorStore.getState().site!.pages[0].nodes.root.label).toBeUndefined() + expect(useEditorStore.getState().site!.pages[0].title).toBe(page.title) + useEditorStore.getState().redo() + expect(useEditorStore.getState().site!.pages[0].nodes.root.label).toBe('Renamed root') +}) + +it('restores an untouched deleted page through roster undo', () => { + const first = makePage() + const second = makePage({ id: 'untouched', title: 'Untouched', slug: 'untouched' }) + useEditorStore.getState().loadSite(makeSite({ pages: [first, second] })) + useEditorStore.getState().deletePage(second.id) + expect(useEditorStore.getState().site!.pages.map((p) => p.id)).toEqual([first.id]) + useEditorStore.getState().undo() + expect(useEditorStore.getState().site!.pages).toEqual([first, second]) + useEditorStore.getState().redo() + expect(useEditorStore.getState().site!.pages.map((p) => p.id)).toEqual([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..c48aa5af2 100644 --- a/src/admin/pages/site/store/slices/site/collabBinding.ts +++ b/src/admin/pages/site/store/slices/site/collabBinding.ts @@ -22,7 +22,7 @@ * * Modes: * - DETACHED (default; tests, and the window before the socket connects): - * docs live locally, seeded from the loaded site. Everything works + * docs live locally, seeded on first use from the loaded site. Everything works * single-user with no transport. * - CONNECTED (editor runtime): `connectCollabProvider` rebinds every doc * through the CollabProvider — empty docs that the SERVER seeds (the @@ -48,10 +48,6 @@ import { projectPageDoc, projectSiteDoc, rostersMap, - seedComponentDoc, - seedLayoutDoc, - seedPageDoc, - seedSiteDoc, SEED_ORIGIN, shellMap, siteDocId, @@ -59,6 +55,7 @@ import { type CollabDocSet, } from '@core/collab' import { allDocIdsForSite, collabBranchId, notifyCollabBranchGone } from './collabBranch' +import { createDetachedCollabDocSet } from './detachedCollabDocs' import { clonePackageJson } from '@core/site-dependencies/manifest' import { cloneSiteRuntimeConfig } from '@core/site-runtime' import { validateSite } from '@core/persistence/validate' @@ -554,7 +551,7 @@ function projectDocIntoStore(docId: string): void { /** * Reset the doc world to mirror a freshly loaded site (or nothing). In - * detached mode the docs are seeded locally; in connected mode every doc + * detached mode the docs seed locally on first use; in connected mode every doc * rebinds through the provider (server-seeded). */ export function resetCollabDocsFromSite(site: SiteDocument | null): void { @@ -581,33 +578,7 @@ export function resetCollabDocsFromSite(site: SiteDocument | null): void { bindThroughProvider(allDocIdsForSite(site)) return } - seedDetachedDocs(site) -} - -function seedDetachedDocs(site: SiteDocument): void { - const branchId = collabBranchId() - const shellDocId = siteDocId(branchId) - const siteDoc = docs.ensure(shellDocId) - seedSiteDoc(siteDoc, site) - ensureManaged(shellDocId, siteDoc) - for (const page of site.pages) { - const docId = encodeCollabDocId({ kind: 'page', branchId, rowId: page.id }) - const doc = docs.ensure(docId) - seedPageDoc(doc, page) - ensureManaged(docId, doc) - } - for (const vc of site.visualComponents) { - const docId = encodeCollabDocId({ kind: 'component', branchId, rowId: vc.id }) - const doc = docs.ensure(docId) - seedComponentDoc(doc, vc) - ensureManaged(docId, doc) - } - for (const layout of site.layouts) { - const docId = encodeCollabDocId({ kind: 'layout', branchId, rowId: layout.id }) - const doc = docs.ensure(docId) - seedLayoutDoc(doc, layout) - ensureManaged(docId, doc) - } + docs = createDetachedCollabDocSet(site, collabBranchId(), ensureManaged) } function bindDocThroughProvider(docId: string): void { diff --git a/src/admin/pages/site/store/slices/site/detachedCollabDocs.ts b/src/admin/pages/site/store/slices/site/detachedCollabDocs.ts new file mode 100644 index 000000000..fc530b697 --- /dev/null +++ b/src/admin/pages/site/store/slices/site/detachedCollabDocs.ts @@ -0,0 +1,47 @@ +import type * as Y from 'yjs' +import type { SiteDocument } from '@core/page-tree' +import { + createCollabDocSet, encodeCollabDocId, seedComponentDoc, seedLayoutDoc, + seedPageDoc, seedSiteDoc, siteDocId, type CollabDocSet, +} from '@core/collab' + +/** + * Detached rows retain their initial JSON until first use, rather than building + * a second copy of every tree in Yjs before the provider connects. Keeping the + * seed for unused rows also lets a roster undo restore an untouched deleted row. + */ +export function createDetachedCollabDocSet( + site: SiteDocument, + branchId: string, + onCreate: (docId: string, doc: Y.Doc) => void, +): CollabDocSet { + const docs = createCollabDocSet() + const seeds = new Map void>() + seeds.set(siteDocId(branchId), (doc) => seedSiteDoc(doc, site)) + for (const page of site.pages) { + seeds.set(encodeCollabDocId({ kind: 'page', branchId, rowId: page.id }), (doc) => seedPageDoc(doc, page)) + } + for (const component of site.visualComponents) { + seeds.set(encodeCollabDocId({ kind: 'component', branchId, rowId: component.id }), (doc) => seedComponentDoc(doc, component)) + } + for (const layout of site.layouts) { + seeds.set(encodeCollabDocId({ kind: 'layout', branchId, rowId: layout.id }), (doc) => seedLayoutDoc(doc, layout)) + } + const ensure = (docId: string): Y.Doc => { + const existing = docs.get(docId) + if (existing) return existing + const doc = docs.ensure(docId) + seeds.get(docId)?.(doc) + seeds.delete(docId) + // Seed first so the initial content is outside the local undo history. + onCreate(docId, doc) + return doc + } + return { + get: (docId) => docs.get(docId) ?? (seeds.has(docId) ? ensure(docId) : undefined), + ensure, + set: (docId, doc) => { seeds.delete(docId); docs.set(docId, doc) }, + delete: (docId) => { seeds.delete(docId); docs.delete(docId) }, + entries: () => docs.entries(), + } +}