Skip to content

Commit 359af78

Browse files
snorreesclaude
andcommitted
feat: SyncClient hydration, fix ref doc creation, defer incomplete refs
Three fixes to sanity-rooms: 1. SyncClient hydration: initialState is now optional. Client connects immediately, hydrates from the server room's first state message. ready promise, isHydrated getter, mutation guards before hydration. Editor no longer needs an HTTP pre-fetch for initial config. 2. Ref doc creation: editDocument fails on non-existent docs (SDK learnings were wrong — proved with throwaway script). Bridge now uses createDocument + editDocument for new ref docs, tracks known docs via markRefDocKnown to avoid duplicate creates. Mock updated to enforce real SDK constraints. 3. Defer incomplete refs: handleSanityChange now skips mapping when ref bridges haven't loaded yet, preventing broadcast of state with stripped custom resources. Ref bridge onChange re-triggers mapping once loaded. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d2897d3 commit 359af78

7 files changed

Lines changed: 316 additions & 17 deletions

File tree

sanity-sdk-learnings.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,10 @@ When you subscribe to a doc via `getDocumentState`, the SDK creates an entry in
5555
### `editDocument` vs `createDocument`
5656

5757
- `createDocument` throws if a draft already exists (`"A draft version already exists"`)
58-
- `editDocument` with `{ set: fields }` works as an **upsert** — it creates the draft if it doesn't exist, updates it if it does. This is the correct choice for ref documents (custom fonts, palettes, backgrounds) where the doc may or may not exist.
58+
- `editDocument` **requires the doc to exist** in draft or published form — it throws `"Cannot edit document because it does not exist in draft or published form"` otherwise. It is NOT an upsert.
59+
- `createDocument` + `editDocument` in the same `applyDocumentActions` batch WORKS — the create runs first, then the edit sees the newly created draft.
5960

60-
**Use `editDocument` for ref docs, not `createDocument`.** Using `createDocument` in a batch with the main doc edit causes the entire batch to fail if the ref doc draft already exists — silently dropping the main doc write too.
61+
**For ref docs that may or may not exist:** batch `createDocument(handle)` before `editDocument(handle, { set: ... })`. If the doc already exists, skip the `createDocument`. The bridge must track which ref docs have been created to choose the right action.
6162

6263
## Server-side reference integrity
6364

src/__tests__/sanity-bridge.test.ts

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ describe('SanityBridge', () => {
8383
bridge.dispose()
8484
})
8585

86-
it('write with refDocs uses editDocument not createDocument (Bug #3)', async () => {
86+
it('write with existing ref doc edits without creating', async () => {
8787
const mock = createMockSanity({ 'doc-1': { value: 1 }, 'ref-1': { name: 'existing' } })
8888
const bridge = new SanityBridge({
8989
instance: mock.instance,
@@ -94,16 +94,94 @@ describe('SanityBridge', () => {
9494
})
9595
await flushMicrotasks()
9696

97-
// Write main doc + ref doc (ref doc already exists as draft)
97+
// Tell the bridge ref-1 already exists in Sanity
98+
bridge.markRefDocKnown('ref-1')
99+
98100
bridge.write(
99101
{ value: 2 },
100102
[{ docId: 'ref-1', documentType: 'customFont', content: { name: 'updated' } }],
101103
)
102104
await flushMicrotasks()
103105

104-
// Main doc should be updated
105106
expect(mock.getDoc('doc-1')?.value).toBe(2)
106-
// Ref doc should be updated (not fail because it already exists)
107+
expect(mock.getDoc('ref-1')?.name).toBe('updated')
108+
bridge.dispose()
109+
})
110+
111+
it('write with new ref doc creates then edits transparently', async () => {
112+
const mock = createMockSanity({ 'doc-1': { value: 1 } })
113+
const bridge = new SanityBridge({
114+
instance: mock.instance,
115+
resource: mock.resource,
116+
docId: 'doc-1',
117+
documentType: 'test',
118+
onChange: () => {},
119+
})
120+
await flushMicrotasks()
121+
122+
// ref doc doesn't exist — bridge should create + edit automatically
123+
bridge.write(
124+
{ value: 2 },
125+
[{ docId: 'new-ref', documentType: 'customBackground', content: { name: 'new bg' } }],
126+
)
127+
await flushMicrotasks()
128+
129+
expect(mock.getDoc('doc-1')?.value).toBe(2)
130+
expect(mock.getDoc('new-ref')?.name).toBe('new bg')
131+
expect(mock.getDoc('new-ref')?._id).toBe('new-ref')
132+
bridge.dispose()
133+
})
134+
135+
it('second write to same ref doc skips create', async () => {
136+
const mock = createMockSanity({ 'doc-1': { value: 1 } })
137+
const bridge = new SanityBridge({
138+
instance: mock.instance,
139+
resource: mock.resource,
140+
docId: 'doc-1',
141+
documentType: 'test',
142+
onChange: () => {},
143+
})
144+
await flushMicrotasks()
145+
146+
// First write — creates the ref doc
147+
bridge.write(
148+
{ value: 2 },
149+
[{ docId: 'new-ref', documentType: 'customBackground', content: { name: 'v1' } }],
150+
)
151+
await flushMicrotasks()
152+
expect(mock.getDoc('new-ref')?.name).toBe('v1')
153+
154+
// Second write — should NOT try createDocument again (would throw "already exists")
155+
bridge.write(
156+
{ value: 3 },
157+
[{ docId: 'new-ref', documentType: 'customBackground', content: { name: 'v2' } }],
158+
)
159+
await flushMicrotasks()
160+
expect(mock.getDoc('new-ref')?.name).toBe('v2')
161+
bridge.dispose()
162+
})
163+
164+
it('markRefDocKnown prevents unnecessary create on existing docs', async () => {
165+
const mock = createMockSanity({ 'doc-1': { value: 1 }, 'ref-1': { name: 'existing' } })
166+
const bridge = new SanityBridge({
167+
instance: mock.instance,
168+
resource: mock.resource,
169+
docId: 'doc-1',
170+
documentType: 'test',
171+
onChange: () => {},
172+
})
173+
await flushMicrotasks()
174+
175+
// Without markRefDocKnown, writing would try createDocument on an existing doc → fail
176+
// With it, the bridge knows to skip createDocument
177+
bridge.markRefDocKnown('ref-1')
178+
179+
bridge.write(
180+
{ value: 2 },
181+
[{ docId: 'ref-1', documentType: 'customFont', content: { name: 'updated' } }],
182+
)
183+
await flushMicrotasks()
184+
107185
expect(mock.getDoc('ref-1')?.name).toBe('updated')
108186
bridge.dispose()
109187
})

src/__tests__/sync-client.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,3 +455,126 @@ describe('SyncClient diff-at-flush', () => {
455455
syncClient.dispose()
456456
})
457457
})
458+
459+
// ── Hydration tests ───────────────────────────────────────────────────────
460+
461+
function makeUnhydratedClient() {
462+
const { client: transport, server } = createMemoryTransportPair()
463+
const syncClient = new SyncClient({
464+
transport,
465+
documents: {
466+
main: { applyMutation: replaceApply },
467+
},
468+
sendDebounce: { ms: 0, maxWaitMs: 0 },
469+
})
470+
return { syncClient, server, transport }
471+
}
472+
473+
describe('SyncClient hydration', () => {
474+
it('ready resolves immediately when all docs have initialState', async () => {
475+
const { syncClient } = makeClient({ count: 0 })
476+
await syncClient.ready // should not hang
477+
expect(syncClient.isHydrated).toBe(true)
478+
syncClient.dispose()
479+
})
480+
481+
it('ready resolves after server sends state for unhydrated doc', async () => {
482+
const { syncClient, server } = makeUnhydratedClient()
483+
expect(syncClient.isHydrated).toBe(false)
484+
485+
let resolved = false
486+
syncClient.ready.then(() => { resolved = true })
487+
await flushMicrotasks()
488+
expect(resolved).toBe(false)
489+
490+
server.send({ channel: 'doc:main', type: 'state', state: { count: 42 } } satisfies ServerMsg)
491+
await flushMicrotasks()
492+
493+
expect(resolved).toBe(true)
494+
expect(syncClient.isHydrated).toBe(true)
495+
syncClient.dispose()
496+
})
497+
498+
it('getDocState throws before hydration', () => {
499+
const { syncClient } = makeUnhydratedClient()
500+
expect(() => syncClient.getDocState('main')).toThrow('not hydrated')
501+
syncClient.dispose()
502+
})
503+
504+
it('mutate throws before hydration', () => {
505+
const { syncClient } = makeUnhydratedClient()
506+
expect(() => syncClient.mutate('main', { kind: 'replace', state: { count: 1 } })).toThrow('before hydration')
507+
syncClient.dispose()
508+
})
509+
510+
it('getDocState works after hydration', async () => {
511+
const { syncClient, server } = makeUnhydratedClient()
512+
server.send({ channel: 'doc:main', type: 'state', state: { count: 99 } } satisfies ServerMsg)
513+
await flushMicrotasks()
514+
515+
expect(syncClient.getDocState('main')).toEqual({ count: 99 })
516+
syncClient.dispose()
517+
})
518+
519+
it('subscribers notified on hydration', async () => {
520+
const { syncClient, server } = makeUnhydratedClient()
521+
const listener = vi.fn()
522+
syncClient.subscribeDoc('main', listener)
523+
524+
server.send({ channel: 'doc:main', type: 'state', state: { count: 1 } } satisfies ServerMsg)
525+
await flushMicrotasks()
526+
527+
expect(listener).toHaveBeenCalledTimes(1)
528+
syncClient.dispose()
529+
})
530+
531+
it('mixed docs: ready waits for all unhydrated docs', async () => {
532+
const { client: transport, server } = createMemoryTransportPair()
533+
const syncClient = new SyncClient({
534+
transport,
535+
documents: {
536+
hydrated: { initialState: 'ready', applyMutation: replaceApply },
537+
pending: { applyMutation: replaceApply },
538+
},
539+
sendDebounce: { ms: 0, maxWaitMs: 0 },
540+
})
541+
542+
expect(syncClient.isHydrated).toBe(false)
543+
expect(syncClient.isDocHydrated('hydrated')).toBe(true)
544+
expect(syncClient.isDocHydrated('pending')).toBe(false)
545+
546+
let resolved = false
547+
syncClient.ready.then(() => { resolved = true })
548+
await flushMicrotasks()
549+
expect(resolved).toBe(false)
550+
551+
server.send({ channel: 'doc:pending', type: 'state', state: 'loaded' } satisfies ServerMsg)
552+
await flushMicrotasks()
553+
554+
expect(resolved).toBe(true)
555+
expect(syncClient.isHydrated).toBe(true)
556+
expect(syncClient.getDocState('pending')).toBe('loaded')
557+
expect(syncClient.getDocState('hydrated')).toBe('ready')
558+
syncClient.dispose()
559+
})
560+
561+
it('mutations work normally after hydration', async () => {
562+
vi.useFakeTimers()
563+
const { syncClient, server } = makeUnhydratedClient()
564+
565+
server.send({ channel: 'doc:main', type: 'state', state: { count: 10 } } satisfies ServerMsg)
566+
await vi.advanceTimersByTimeAsync(0)
567+
568+
syncClient.mutate('main', { kind: 'replace', state: { count: 20 } })
569+
expect(syncClient.getDocState('main')).toEqual({ count: 20 })
570+
571+
// Flush should produce a sanityPatch
572+
const received: unknown[] = []
573+
server.onMessage((m) => received.push(m))
574+
await vi.advanceTimersByTimeAsync(0)
575+
576+
expect(received).toHaveLength(1)
577+
expect(asMutateMsg(received[0]).mutation.kind).toBe('sanityPatch')
578+
syncClient.dispose()
579+
})
580+
})

src/client/sync-client.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
*
99
* Named mutations (intent-based, e.g. AI tool calls) still go through the
1010
* traditional mutation queue with ack/reject handling.
11+
*
12+
* Hydration: when `initialState` is omitted, the doc starts unhydrated.
13+
* The `ready` promise resolves once all docs receive their first `state`
14+
* message from the server. Mutations are blocked until hydration completes.
1115
*/
1216

1317
import { diffValue } from '@sanity/diff-patch'
@@ -20,8 +24,11 @@ import { docChannel, parseChannel } from '../channel'
2024
import { type DebouncedFlusher, createFlusher, clearFlusher, scheduleFlusher } from '../debounce'
2125
import { MutationQueue } from './mutation-queue'
2226

27+
const UNHYDRATED = Symbol('unhydrated')
28+
2329
export interface DocConfig {
24-
initialState: unknown
30+
/** Initial state. Omit to wait for the server's first `state` message. */
31+
initialState?: unknown
2532
applyMutation: (state: unknown, mutation: Mutation) => unknown | null
2633
reconcile?: (prev: unknown, next: unknown) => unknown
2734
}
@@ -43,6 +50,8 @@ interface DocState {
4350
listeners: Set<() => void>
4451
/** Whether localState has unsent changes (replace mutations not yet flushed). */
4552
dirty: boolean
53+
/** Whether this doc has received its initial state (from constructor or server). */
54+
hydrated: boolean
4655
}
4756

4857
type StatusListener = (status: 'connected' | 'disconnected') => void
@@ -67,24 +76,36 @@ export class SyncClient {
6776
private debounceMs: number
6877
private maxWaitMs: number
6978
private disposed = false
79+
private _resolveReady: (() => void) | null = null
80+
81+
/** Resolves when all docs have received their initial state. */
82+
readonly ready: Promise<void>
7083

7184
constructor(options: SyncClientOptions) {
7285
this.transport = options.transport
7386
this.debounceMs = options.sendDebounce?.ms ?? 500
7487
this.maxWaitMs = options.sendDebounce?.maxWaitMs ?? 1000
7588

7689
for (const [docId, config] of Object.entries(options.documents)) {
90+
const hasInitial = config.initialState !== undefined
7791
this.docs.set(docId, {
78-
serverState: config.initialState,
79-
localState: config.initialState,
80-
lastSentState: config.initialState,
92+
serverState: hasInitial ? config.initialState : UNHYDRATED,
93+
localState: hasInitial ? config.initialState : UNHYDRATED,
94+
lastSentState: hasInitial ? config.initialState : UNHYDRATED,
8195
queue: new MutationQueue(),
8296
config,
8397
listeners: new Set(),
8498
dirty: false,
99+
hydrated: hasInitial,
85100
})
86101
}
87102

103+
if ([...this.docs.values()].every(d => d.hydrated)) {
104+
this.ready = Promise.resolve()
105+
} else {
106+
this.ready = new Promise(resolve => { this._resolveReady = resolve })
107+
}
108+
88109
this.unsubMessage = this.transport.onMessage((raw) => {
89110
if (isServerMsg(raw)) this.handleServerMsg(raw)
90111
})
@@ -99,6 +120,7 @@ export class SyncClient {
99120
getDocState<T = unknown>(docId: string): T {
100121
const doc = this.docs.get(docId)
101122
if (!doc) throw new Error(`Unknown document: ${docId}`)
123+
if (!doc.hydrated) throw new Error(`Document "${docId}" not hydrated — await client.ready`)
102124
return doc.localState as T
103125
}
104126

@@ -109,11 +131,24 @@ export class SyncClient {
109131
return () => { doc.listeners.delete(listener) }
110132
}
111133

134+
// ── Hydration ─────────────────────────────────────────────────────────
135+
136+
get isHydrated(): boolean {
137+
return [...this.docs.values()].every(d => d.hydrated)
138+
}
139+
140+
isDocHydrated(docId: string): boolean {
141+
const doc = this.docs.get(docId)
142+
if (!doc) throw new Error(`Unknown document: ${docId}`)
143+
return doc.hydrated
144+
}
145+
112146
// ── Mutations ───────────────────────────────────────────────────────────
113147

114148
mutate(docId: string, mutation: Mutation): void {
115149
const doc = this.docs.get(docId)
116150
if (!doc) throw new Error(`Unknown document: ${docId}`)
151+
if (!doc.hydrated) throw new Error(`Cannot mutate "${docId}" before hydration — await client.ready`)
117152

118153
if (mutation.kind === 'replace') {
119154
// Replace: update local state directly, diff at flush time
@@ -207,6 +242,20 @@ export class SyncClient {
207242
case 'state': {
208243
const received = msg.state
209244

245+
if (!doc.hydrated) {
246+
// First state message — hydrate
247+
doc.serverState = received
248+
doc.localState = received
249+
doc.lastSentState = received
250+
doc.hydrated = true
251+
for (const listener of doc.listeners) listener()
252+
if ([...this.docs.values()].every(d => d.hydrated)) {
253+
this._resolveReady?.()
254+
this._resolveReady = null
255+
}
256+
break
257+
}
258+
210259
if (this._status === 'disconnected') {
211260
// Reconnect: full reset — accept server state, discard unsent local edits
212261
doc.serverState = received
@@ -259,6 +308,7 @@ export class SyncClient {
259308

260309
/** Recompute local state by replaying queued named mutations on server state. */
261310
private recomputeLocal(doc: DocState): void {
311+
if (!doc.hydrated) return
262312
const prev = doc.localState
263313
let next = doc.queue.rebase(doc.serverState, doc.config.applyMutation)
264314
if (doc.config.reconcile) {
@@ -272,7 +322,7 @@ export class SyncClient {
272322
private flush(): void {
273323
// Diff dirty docs and produce sanityPatch mutations
274324
for (const [docId, doc] of this.docs) {
275-
if (!doc.dirty) continue
325+
if (!doc.hydrated || !doc.dirty) continue
276326

277327
const operations = diffValue(doc.lastSentState, doc.localState)
278328
if (operations.length === 0) {

0 commit comments

Comments
 (0)