jotdocs

Concepts / Offline and merging

Offline and merging

Two copies of a Yjs document merge whatever each did while apart: each sends what the other is missing, and both end up with the same content, in any order. That's all reconnecting is.

merge.ts
import * as Y from '@y/y';

/**
 * Two copies of one document edit apart, then exchange what the other is
 * missing. Each sends only the updates past the other's state vector, and
 * both end up with the same content, whatever order the updates arrive in.
 */
export function editApartThenMerge() {
  const you = new Y.Doc();
  const ana = new Y.Doc();
  you.get('notes').insert(0, 'Bring snacks.');
  Y.applyUpdate(ana, Y.encodeStateAsUpdate(you)); // both start from the same text

  // Offline: neither sees the other's edit yet.
  you.get('notes').insert(0, 'Friday: ');
  ana.get('notes').insert(13, ' And plates.');

  // Back online: sync step 1 sends a state vector, step 2 answers with the difference.
  const toAna = Y.encodeStateAsUpdate(you, Y.encodeStateVector(ana));
  const toYou = Y.encodeStateAsUpdate(ana, Y.encodeStateVector(you));
  Y.applyUpdate(ana, toAna);
  Y.applyUpdate(you, toYou);

  return [you.get('notes').toString(), ana.get('notes').toString()];
}

Work offline

jot makes this something you can try: ⋯ → Work offline, or a click on the status pill, closes the note's socket and keeps it editable. The pill counts what's waiting ("Offline · 3 edits waiting") until you press Reconnect; once the server has confirmed every edit, it says "Back online · merged" for a few seconds. Open the note in two windows, take one offline, type in both, and reconnect.

work-offline.ts
import type { WebsocketProvider } from '@y/websocket';
import type * as Y from '@y/y';

/**
 * Work offline: close the socket and keep editing. The document keeps every
 * local transaction; reconnecting runs the sync steps, and both sides merge.
 */
export function workOffline(provider: WebsocketProvider, doc: Y.Doc) {
  let waiting = 0;
  const count = (_update: Uint8Array, origin: unknown) => {
    if (origin !== provider) waiting++; // a local edit the server hasn't seen
  };
  provider.disconnect();
  doc.on('update', count);
  return {
    edits: () => waiting,
    reconnect() {
      doc.off('update', count);
      provider.connect();
    },
  };
}

What happens on reconnect

  1. The socket reopens. On a lost network the provider retries with backoff.
  2. The server sends sync step 1: its state vector, a compact summary of what it has.
  3. The client answers with sync step 2: every update past that vector, its offline edits included. It sends its own step 1, and the server answers the same way.
  4. The server applies the client's updates and sends them to every socket, the sender's included: @y/websocket takes its own updates coming back as confirmation.

When the browser says the network is back, jot doesn't wait for the next retry, unless you chose to work offline:

reconnect.ts
import type { WebsocketProvider } from '@y/websocket';

/**
 * The provider retries on its own with backoff. When the browser says the
 * network is back, skip the wait and reconnect at once, unless the person
 * chose to work offline; sync step 1 and 2 then exchange whatever either side
 * wrote while apart.
 */
export function reconnectWhenOnline(provider: WebsocketProvider, workOffline: () => boolean) {
  const onOnline = () => {
    if (!provider.wsconnected && !workOffline()) {
      provider.disconnect();
      provider.connect();
    }
  };
  window.addEventListener('online', onOnline);
  return () => window.removeEventListener('online', onOnline);
}

Where offline edits live

In memory. There's no Yjs 14 IndexedDB provider yet, so a note's unsynced edits stay in the page until the socket is back, and closing its tab while offline loses them. A full note is the one exception to "nothing is lost": an edit that would pass 2 MB is refused when it arrives.