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.
Initial sync projections collect socket tasks over a 16 ms frame and commit
rows plus their roster to the store together. Write gates open after that
projection, so tools never edit a stale HTTP snapshot. Live remote updates
retain microtask projection; undo/redo project synchronously.
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
8 changes: 4 additions & 4 deletions src/__tests__/collab/paletteBurstWrite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
CollabResetListener,
} from '@site/collab/collabProvider'
import { clearCollabBlockNotice } from '@site/store/slices/site/collabNotices'
import { whenCollabWritable } from '@site/store/slices/site/collabWriteGate'
import { useEditorStore } from '@site/store/store'
import { runSetColorTokens } from '@site/agent/tokenRunners'
import '@modules/base/index'
Expand Down Expand Up @@ -102,8 +103,7 @@ describe('installing a colour palette in one call', () => {
const provider = deferredProvider()
connectCollabProvider(provider)
provider.releaseAll()
// Let the whenSynced promises settle so the gates are open.
await new Promise((resolve) => setTimeout(resolve, 10))
expect(await whenCollabWritable()).toBe(true)

const result = useEditorStore.getState().upsertFrameworkColorTokens(PALETTE)

Expand All @@ -117,7 +117,7 @@ describe('installing a colour palette in one call', () => {
const provider = deferredProvider()
connectCollabProvider(provider)
provider.releaseAll()
await new Promise((resolve) => setTimeout(resolve, 10))
expect(await whenCollabWritable()).toBe(true)

// Two DIFFERENT entries normalizing to the same slug must not collapse:
// the second is a distinct token and gets a suffixed slug, exactly as a
Expand All @@ -135,7 +135,7 @@ describe('installing a colour palette in one call', () => {
const provider = deferredProvider()
connectCollabProvider(provider)
provider.releaseAll()
await new Promise((resolve) => setTimeout(resolve, 10))
expect(await whenCollabWritable()).toBe(true)

useEditorStore.getState().upsertFrameworkColorTokens(PALETTE)
const second = useEditorStore.getState().upsertFrameworkColorTokens(PALETTE)
Expand Down
92 changes: 92 additions & 0 deletions src/__tests__/collab/projectionBatches.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { afterEach, expect, it } from 'bun:test'
import * as Y from 'yjs'
import { Awareness } from 'y-protocols/awareness'
import '@modules/base'
import { encodeCollabDocId, MAIN_SITE_DOC_ID, metaMap, seedPageDoc, seedSiteDoc } from '@core/collab'
import type { CollabProvider, BoundCollabDoc } from '@site/collab/collabProvider'
import { connectCollabProvider, disconnectCollabProvider } from '@site/store/slices/site/collabBinding'
import { whenCollabWritable } from '@site/store/slices/site/collabWriteGate'
import { clearCollabBlockNotice } from '@site/store/slices/site/collabNotices'
import { useEditorStore } from '@site/store/store'
import { makePage, makeSite } from '../fixtures'

function seededProvider(site: ReturnType<typeof makeSite>) {
const presence = new Y.Doc()
const awareness = new Awareness(presence)
const bound = new Map<string, BoundCollabDoc>()
const releases: Array<() => void> = []
const provider: CollabProvider = {
bind(id) {
const existing = bound.get(id)
if (existing) return existing
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<void>((resolve) => { release = resolve })
const entry = { doc, synced: false, whenSynced }
bound.set(id, entry)
releases.push(() => { entry.synced = true; release() })
return entry
},
unbind(id) { bound.get(id)?.doc.destroy(); bound.delete(id) },
awareness,
status: () => 'connected',
canSend: () => true,
reconnectNow() {},
onStatus: () => () => {},
onReset: () => () => {},
destroy() {
for (const entry of bound.values()) entry.doc.destroy()
awareness.destroy()
presence.destroy()
},
}
return { provider, releaseAll: () => { for (const release of releases) release() } }
}

function pageId(id: string) {
return encodeCollabDocId({ kind: 'page', branchId: 'main', rowId: id })
}

afterEach(() => {
disconnectCollabProvider()
useEditorStore.getState().clearSite()
clearCollabBlockNotice()
})

it('commits an initial row burst once and opens writes only after its projection', async () => {
const pages = Array.from({ length: 30 }, (_, i) => makePage({ id: `page-${i}`, slug: `page-${i}` }))
const site = makeSite({ pages })
useEditorStore.getState().loadSite(site)
const { provider, releaseAll } = seededProvider(makeSite({
pages: pages.map((p, i) => ({ ...p, title: `Server ${i}` })),
}))
connectCollabProvider(provider)
let changes = 0
const off = useEditorStore.subscribe((next, previous) => {
if (next.site !== previous.site) changes++
})
try {
releaseAll()
await Promise.resolve()
expect(await whenCollabWritable(1)).toBe(false)
useEditorStore.getState().updateSiteName('Premature write')
expect(useEditorStore.getState().site!.name).toBe(site.name)
expect(await whenCollabWritable()).toBe(true)
expect(changes).toBe(1)
expect(useEditorStore.getState().site!.pages.map((p) => p.title)).toEqual(
pages.map((_p, i) => `Server ${i}`),
)
// After startup a live peer edit still projects in the next microtask.
metaMap(provider.bind(pageId(pages[0].id)).doc).set('title', 'Live edit')
await Promise.resolve()
expect(useEditorStore.getState().site!.pages[0].title).toBe('Live edit')
expect(changes).toBe(2)
} finally {
off()
}
})
Loading