Initial offline value of a shared document

@joshuafontany Yes, that is correct. If the clientID and the clock are the same, the update is idempotent.

This question was never adequately answered, and I believe the answer is yes, it is basically the same.

Given how common this question is, I’m going to provide a reusable solution based on the dreaded clientID manipulation. I really don’t think users have been given a good alternative yet. As long as the Doc is synced with the same initial content on every client, I see no issue at all. Please correct me if I’m wrong. (And note that this is WAY better than waiting for the provider to sync. That’s not offline-first!)

Remember: Never sync docs with different initial content. i.e. Always construct the Doc with the same initial content.

Here we go. I just extended Y.Doc with a constructor param to provide initial content:

class TemplateDoc extends Y.Doc {
  constructor(options) {
    super(options)
    if (options?.init) {
      const clientID = this.clientID
      this.clientID = 0
      this.transact(() => options.init?.(this))
      this.clientID = clientID
    }
  }
}

And here it is in action, with the naive approach that duplicates content as a comparison:

Demo: View in CodeSandbox

import * as Y from 'yjs'

/** Syncs two Docs. */
const sync = (doc1, doc2) => {
  const state1 = Y.encodeStateAsUpdate(doc1)
  const state2 = Y.encodeStateAsUpdate(doc2)
  Y.applyUpdate(doc1, state2)
  Y.applyUpdate(doc2, state1)
}

const initialContent = 'This is your new document.'

// Naive approach - duplicate initial content

const doc1 = new Y.Doc()
const doc2 = new Y.Doc()

doc1.getText().insert(0, initialContent)
doc1.getText().insert(initialContent.length, ' Uh oh.')

doc2.getText().insert(0, initialContent)
doc2.getText().insert(initialContent.length, ' Oh no!')

sync(doc1, doc2)

console.log('doc1', doc1.getText().toString())
console.log('doc2', doc2.getText().toString())

// TemplateDoc - insert idempotent initial content

const init = (doc: Y.Doc) => {
  doc.getText().insert(0, initialContent)
}
const docWithTemplate1 = new TemplateDoc({ init })
const docWithTemplate2 = new TemplateDoc({ init })

docWithTemplate1.getText().insert(initialContent.length, ' Fantastic.')
docWithTemplate2.getText().insert(initialContent.length, ' Wonderful.')

sync(docWithTemplate1, docWithTemplate2)

console.log('docWithTemplate1', docWithTemplate1.getText().toString())
console.log('docWithTemplate2', docWithTemplate2.getText().toString())

Output:

doc1 This is your new document. Oh no!This is your new document. Uh oh.
doc2 This is your new document. Oh no!This is your new document. Uh oh.
docWithTemplate1 This is your new document. Wonderful. Fantastic.
docWithTemplate2 This is your new document. Wonderful. Fantastic.

Since this clientID is only manipulated when the Doc is created, there is no risk of breakage.

Just make sure to never sync two docs with different initial content.

Also, once a client uses the initial content, you can never change the value in your code. Think of the initial content of a Doc as part of its schema. You would need a migration strategy to change it (just like if you wanted to change the shared types for code that’s already in production).

2 Likes