Skip to content
Draft
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 docs/features/site-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
52 changes: 52 additions & 0 deletions src/__tests__/collab/detachedDocs.test.ts
Original file line number Diff line number Diff line change
@@ -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])
})
37 changes: 4 additions & 33 deletions src/admin/pages/site/store/slices/site/collabBinding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -48,17 +48,14 @@ import {
projectPageDoc,
projectSiteDoc,
rostersMap,
seedComponentDoc,
seedLayoutDoc,
seedPageDoc,
seedSiteDoc,
SEED_ORIGIN,
shellMap,
siteDocId,
treeMap,
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'
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
47 changes: 47 additions & 0 deletions src/admin/pages/site/store/slices/site/detachedCollabDocs.ts
Original file line number Diff line number Diff line change
@@ -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<string, (doc: Y.Doc) => 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(),
}
}