(Un)expected yMap key behaviour for reactive components in Observable notebook

Maybe it is naive to assume this to work out of the box, but I was hoping someone would be able to shed some light on this issue or what I am missing. I am trying to sync the state of multiple clients visiting an Observable notebook, demonstrated here for a simple range slider component:

The issue I am having is that when a second client enters a room, the ‘slider’ key is deleted from the yMap on interaction with the range slider. The key rematerialises when the first client adjusts the slider again. I have observed this behaviour under different circumstances, leading me to believe this works as intended and ObservableHQ’s dataflow/sandboxing is throwing some weirdness into the mix.

I’ve also tried using SyncedStore, which is exhibiting the same issue when a second client writes to the store. It seems I need to intercept the yMap ‘delete’ action somehow, making me wonder why the delete action is initiated when a second client sends updates to the ‘slider’ key in the first place?

I haven’t used ObservableHQ before, but it really looks cool. Anyway, … I really can’t give any useful input because I have no experience with the tool.

1 Like

Not to worry, it’s runtime is a beast on itself. It involves firing up a dedicated js-worker for each notebook and streaming the results to a sandboxed iframe, with each ‘cell’ functioning as a standalone ES6 module connected via a DAG.

Just wondering then if in a ‘vanilla’ javascript context we are able to update a single yMap key this way:

  1. Create a yMap
  2. Set a single key called ‘slider’
  3. Add an event listener to an input element to respond to slider changes
  4. Have the key update to reflect the input value across clients without the key being deleted on entry (synced state on a single key/value).

What I am trying to understand here, is whether yJs deletes the key in the first client’s contexts from detecting a duplicated event listener on the same element, or that it actively tries to prevent the second client overwriting a key set by the first client.

At the beginning of a session, you are not synced. After a time, the clients will sync with each other and you will see the latest value. Hence, it is important that you don’t overwrite content before you synced with the other clients.

const ymap = ydoc.getMap('my map') doesn’t write any content to a Yjs document (hence the get keyword). Only when calling something like ymap.set('value', someValue) you will write content. Adding event listeners also doesn’t manipulate a Yjs document.

There is a possibility that your code populates the ymap accidentally with initial content of the slider. At the beginning of a session, the client would set the value of the slider to value X, which will trigger an event listener that updates the value of the ymap. This change can potentially overwrite the content from remote clients (in case of a conflict there is a 50/50 chance that the new client overwrites the existing content).

A collaborative slider could be implemented safely, without overwriting remote content like this:


const ymap = ydoc.getMap('slider')

ymap.observe(event => {
  slider.setVal(ymap.get('value'))
})

/**
 * Set the default value before you add the slider.on('change', ..) event listener.
 * Otherwise every new clients set the value of ymap reverting it to the old state.
 */
slider.setVal(0)

slider.on('change', newVal => {
  // only set the new ymap value when the user actively changes the default-state to a new value
  ymap.set('value', newVal)
})

// This is an alternative (overoptimized) version that ensures that no value is written at the beginning of a session:
slider.on('change', newVal => {
  if (newVal !== 0) {
    ymap.set('value', newVal)
  } else {
    ymap.delete('value') // nothing will happen at the beginning of a session, 
  }
  ymap.set('value', newVal)
})

This is just a suggestion. There are probably other ways from preventing clients from accidentally overwriting content at the beginning of a session.

Alternatively, when using y-websocket, you could also just listen to the synced event so your new clients don’t have to populate the content of the ymap before you received the remote content. However, I highly recommend making your code independent from specific providers - the above code is more idiomatic.

websocketProvider.on('synced', () => {..})
1 Like

This definitely works as intended, evidenced here:

As the Observable runtime is sandboxed in an iframe on a different domain (static.observableusercontent.com/next/worker.html) from where notebook contents are interacted with (observablehq.com), could CORS issues throw some problems into the mix?

We pinned it down to a bundling issue caused by yJs being imported twice when importing y-webrtc via skypack/unpkg. In addition, the dynamic Observable import('../yjs?module') fails due to simple-peers not providing a default export, similar to this. I will try to see if client side bundling solves the issue.

I managed to make it work on Observable as demonstrated here, using importmaps to resolve the yJs an WebrtcProvider peer dependencies.

Thank @dmonad for the amazing piece of kit, this is going to add a whole other level of user interaction to data-based notebooks and visualisations.