Issue related to rendering snapshot version changes from database

I am reaching out regarding an implementation challenge I am facing while integrating ProseMirror version history using the Yjs-demo code available here (https://github.com/yjs/yjs-demos/tree/main/prosemirror-versions).

To provide some context, I am storing base64-encoded snapshots on the server. When attempting to retrieve and decode these snapshots upon a page refresh, I encounter an issue – the ProseMirror editor does not render the expected diff changes. Strangely, I can observe the snapshot version changes successfully when staying on the page without a refresh.

I am seeking your expertise to identify and address what might be going wrong in this scenario. Specifically, I have ensured the proper initialization of both the Y.Doc and ProseMirror editor to match the state of the snapshot. Additionally, the initial state of the ProseMirror editor is set correctly.

If you could spare a moment to review the provided information and offer insights into potential solutions or debugging approaches, it would be greatly appreciated.

@raine @dmonad Appreciate your help…

Implentation Code:

const versions = ydoc.getArray('versions')

 const addSnapshotToDatbase = doc => {
    const snapshot = Y.snapshot(doc)
    const encodedSnapshot = Y.encodeSnapshot(snapshot)
    // encode the snapshot and store in db
    const binaryString = String.fromCharCode(...encodedSnapshot);
    const base64Encoded = btoa(binaryString);

    // code to send the base64Encoded string to database  and store on server
}

const getSnapshotsFromDatabase = () => {
    const versionBlobFromDatabase = "SGVsbG8sIFdvcmxkIQ==";
    // decode encoded string
    const decodedString = atob(versionBlobFromDatabase);
    const decodedArray = new Uint8Array(decodedString.length);
    for (let i = 0; i < decodedString.length; i++) {
        decodedArray[i] = decodedString.charCodeAt(i);
    }
    
    renderVersion(editorview, decodedArray, null)
}

const renderVersion = (editorview, snapshotversion, prevSnapshot) => {
    editorview.dispatch(
        editorview.state.tr.setMeta(
            ySyncPluginKey,
            { 
                snapshot: Y.decodeSnapshot(snapshotversion),
                prevSnapshot: prevSnapshot == null ? Y.emptySnapshot : Y.decodeSnapshot(prevSnapshot)
            }
        )
    )
  }

<button onclick="addSnapshotToDatbase(ydoc)">Capture Snapshot</button>

<button onclick="addSnapshotToDatbase(ydoc)">View Snapshot Diff</button>

I’m sorry, I don’t have any experience with ProseMirror.

I would triple check your decoding algorithm. That’s the part that’s most opaque and error-prone.

@raine is there any way we can extract ydoc from Y.snaphot ?

No, a snapshot is just a single state of the data within a Doc. Docs contain the entire history of edits.

One thing I noticed is that the second argument of renderVersion expects a version object:

{
  date: number,
  snapshot: Uint8Array,
  clientID: string
}

But you are just passing Uint8Array (decodedArray).

decoded array is a Uint8Array… it’s a base64 decoded string

the name is confusing here… sorry about that…

I think the naming is fine. You have a type error though. You’re passing a Uint8Array where a { snapshot: Uint8Array } is expected.

I see what you meant… it’s a copy paste issue… I only provided main part of the code
but in my code I’m passing version object

edited my question

1 Like

I might try starting with a working example like the demo and closely comparing the two, or incrementally porting the demo over to your designed use case. There are too many potential causes now (decoding, snapshot handling, ProseMirror, …), so from a debugging perspective I would be testing different hypotheses and narrowing down the problem space.

Sorry I can’t be of more help.

it’s not working on yjs demo code as well

hi @raine, I’m working on the same project and we were able to narrow this down to interaction with y-websocket. If I write the document state and snapshots to localStorage instead, it works across refreshes. I created a branch of the prosemirror-versions demo to reproduce the issue and included a screencap: Show snapshots issue with y-websocket by rebolyte · Pull Request #61 · yjs/yjs-demos · GitHub

My understanding of this so far:

  1. Client creates a ydoc (gets a client ID, call it 123), establishes a connection to websocket server. Server creates a ydoc (gets a client ID, call it 456). Both client and server both send syncStep1 and respond with syncStep2. Initially, the document is empty on the client, so only change is from server.
  2. Client makes changes as ID 123. Client captures snapshots, which contain clock (sequence num) for IDs 123 and 456. Rendering the snapshots is done by y-prosemirror iterating through which items were added/removed here, which works since the current IDs match?
  3. User refreshes the page, so now client has new ID 789 and server has 012. Snapshots don’t line up, because changes made by ID 123 were now transacted on the document by 012?

This is all theory at this point. I don’t yet know enough of how the Snapshot deleteSet/state vector is applied to the internal representation to see the why here. If we got this demo working with y-websocket it would resolve our issue.

Also not sure why incrementing previous snapshot’s client ID is necessary here:

    prevSnapshot.sv.set(
      prevVersion.clientID,
      /** @type {number} */ (prevSnapshot.sv.get(prevVersion.clientID)) + 1
    );

Hi @raine any ideas here?

It’s strange that it works with indexeddb but not websocket. However that should give you a good basis to narrow down the problem because you have a good state and a bad state that you can triangulate from. Does the Doc get synced the same from the websocket as indexeddb? Are the arguments passed to renderSnapshot exactly the same between the websocket version as indexeddb?

I’ve never actually used ProseMirror myself, so I can’t offer anything more specific. I hope you can figure it out.

@jamesirwin I am facing same issue with my app. Were you able to find a solution or diagnose the issue?

@dmonad Can you please help us out here?

My guess is that you forgot to disable garbage-collection on the server.

1 Like

I am using Hocuspocus/server by Tiptap on backend. Do you know if it is possible to disable Garbage Collection in that?

@Maneet Maybe ask the Hocuspocus folks directly.

1 Like

Facing the same issue here, I have tried disabled gc from server side and still no luck.
From my observation, it indeed has something to do with gc setting, since when I’m not refreshing, snapshot version changes work well, but when I refresh page and retreive ydoc via server, it seems that the latest deletion get applied to all history versions.


see that id:3 and id:4 used to be 1234 and 12345 now get changed to 123(latest deletion is applied)
The version list on the left is implemented as below (ReactJS)

const versions = ydoc.getArray('versions')
versions.observe(e=>{
        setVersionList(e.target.toArray())
})
<div>{
            versionList.map((version,i)=>{
              const snap = Y.decodeSnapshot(version.snapshot);
              const tempdoc = Y.createDocFromSnapshot(ydoc, snap);
              return<div key={version.date} onClick={()=>revertChangesSinceSnapshot(version.snapshot)}>id:{i},content:{tempdoc.getText('quill').toString()}</div>
            })}</div>

I also used the same addVersion implementation from the ProseMirror example:

const addVersion = doc => {
	const versions = doc.getArray('versions')
	const prevVersion = versions.length === 0 ? null : versions.get(versions.length - 1)
	const prevSnapshot = prevVersion === null ? Yjs.emptySnapshot : Yjs.decodeSnapshot(prevVersion.snapshot)
	const snapshot = Yjs.snapshot(doc)
	if (prevVersion != null) {
	  // account for the action of adding a version to ydoc
	  prevSnapshot.sv.set(prevVersion.clientID, /** @type {number} */ (prevSnapshot.sv.get(prevVersion.clientID)) + 1)
	}
	if (!Yjs.equalSnapshots(prevSnapshot, snapshot)) {
	  versions.push([{
		date: new Date().getTime(),
		snapshot: Yjs.encodeSnapshot(snapshot),
		clientID: doc.clientID
	  }])
	}
  }

Would appreciate any help on how to troubleshoot, thank you folk!

I have done further research on the issue, here are some testing result for reference:

id scenario issue occur
1 convert ydoc uint8array to base64, persisting in a db via a backend yes
2 convert ydoc uint8array to base64, persisting in localstorage yes
3 persist in leveldb and store in y-websocket server disk yes
4 persist in indexeddb and store in browser no
5 persist in y-websocket server without refresh(store in y-websocket server memory) no

Garbage collection has been disabled for all testings above, it seems to me that we can tell from scenario no.1 and no.2 that websocket isn’t the issue here.
It’s more likely that certain information is lost during the process of encoding Uint8Array to string.
Below is a screenshot of the printed Uint8Array for snapshots before and after a base64 encoding from scenario no.2, they look identical to me


however they actually generate different output from Y.createDocFromSnapshot(ydoc, snapshot).getText(‘quill’).toString()
@dmonad @raine really appreciate if you guys could help look into this issue and point me to a direction for debugging, baffles me a lot. :thinking:
Thank you !