Skip to content

Simplify yjs implementation #3428

Open
Open

Description

@JammingBen

Idea

The yjs implementation grew over time and can be cleaned up a bit. None of the points below change behavior in a way that could lose data, and each one removes code or subtle bugs. They are ordered by payoff.

Points 3 and 4 are breaking changes to the public API and require a new major release.

1. Use transaction.local instead of the own-origin list (useYjsSession.ts)

The meta observer tells remote writes from its own by checking transaction.origin against OWN_META_ORIGINS (six origin constants). Yjs already marks every applied remote update with transaction.local === false. Code that raises its own stale flag (runInitialHydration, applyExternalUpdate) can start the recovery itself, so the observer only needs to react to remote writes. This removes the origin constants, the EXTERNAL_UPDATE_ORIGIN special case in the observer, and the risk of a new own write being mistaken for a remote one because it lacks an origin.

See #3433.

2. Run runInitialHydration only on the first sync (useYjsSession.ts)

onSynced fires again on every reconnect and re-runs the full hydration logic. Mid-session, the drift check and the "flag already up" branch claim recovery with currentContent as the fresh body, but mid-session that is the serialized document, not a fresh fetch. Remote meta changes that arrive with the reconnect sync are already handled by the meta observer. Returning early from onSynced once isReady is set (after the pending seed re-request) removes these mid-session variants and the isReady check inside runInitialHydration.

see #3581

3. Make YjsAdapter.serialize synchronous and reset required (composables/yjs/types.ts)

The only adapter (makeTiptapYjsAdapter) serializes synchronously and implements reset, and hydrate is already required to be synchronous. Dropping the async option and the optional reset removes:

  • the inFlight chaining in createContentReporter
  • the Promise plumbing in serializeDoc, serializeMerged, reportRecovered and the peer-save re-check after serializing
  • the two "your editor cannot recover in-place" lock branches in runStaleRecovery and undoResyncWipe

It also closes a gap where an async serialize could make the dirty check in onRemoteStaleFlag run after the rewrite already arrived.

see #3582

4. Remove the dead local-mode guard in markConflicted (useYjsSession.ts)

No local-mode caller reaches markConflicted: reconcileConflict requires a connected session, and the observer paths only run for remote writes. applyExternalUpdate duplicates the logic for local mode instead. Removing the guard lets applyExternalUpdate call markConflicted() in both modes and only raise the stale flag if (prov).

5. Split the side effect out of serializeMerged (useYjsSession.ts, useAppWrapperYjs.ts)

serializeMerged records the state behind its result as the pending save state. handleExternalFileEvent uses it only to compare against the fetched body, so a stale pending state is left behind whenever no save follows. Return { content, vector } instead, or split it into "serialize" and "register as pending save".

6. Merge isRoomWrite into wasWrittenByRoom (useYjsSession.ts)

isRoomWrite(etag) is wasWrittenByRoom(etag) without the grace period. wasWrittenByRoom(etag, 0) covers it, so the session API gets one function less.

7. Deterministic seeding (optional, largest change)

Build the seed in a throwaway Y.Doc and apply it to the live doc with Y.applyUpdate. The throwaway doc gets a clientID derived from a hash of the etag plus the seed update itself (built once with a fixed clientID first). Two clients seeding the same file version then produce byte-identical updates, which Yjs merges into one copy. Two browsers that parse the body differently get different clientIDs, so the worst case is today's duplication, never an ID collision.

This removes the whole seed arbitration:

  • server: seedGrant.ts, the onStateless hook, the grant hand-off in onDisconnect and afterUnloadDocument
  • client: the SEED_* messages, pendingSeedGrant, requestSeedGrant, abandonSeedGrant, onSeedMessage, seedOnUnsolicitedGrant and the re-request in onSynced
  • the hydrated meta key, hasLocalOnlyContent and the sessionNonce rebuild, because a read-only client's private copy becomes identical to the real seed
  • the seed round trip before the editor mounts, and the empty editor a refused writer mounts until the seed arrives

Caveats: it deliberately breaks Yjs's "unique clientID" convention, so it may only seed an empty fragment and must never be used for recovery (re-applying known IDs into a reset fragment would be a no-op and leave the room empty). It needs a test that two independent seeds of the same body merge into one copy. If the recovery election is moved to a server grant (see the issue about concurrent recoveries duplicating the document), keeping the seed grant instead is equally valid. The point is to pick one coordination approach for both.

See #3433.

8. Smaller tidy-ups

  • services/yjs/src/lib/hooks.ts: the connection.context?.user fallbacks in connected and beforeHandleAwareness are unnecessary, since Hocuspocus already passes the connection's context as context.
  • onRequest checks lifecycle.isReady() && !lifecycle.isShuttingDown(), but onShutdownStart already sets isServerReady to false.
  • services/yjs/src/lib/shutdown.ts (80 lines plus tests) could be about 15 lines in server.ts.
  • dev/docs/yjs.md shows hydrate returning void | Promise<void>, while types.ts requires it to be synchronous.
  • The comment on YjsDeniedReason in useYjsSession.ts points to services/yjs/src/server.ts; the enum lives in services/yjs/src/lib/errors.ts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type:MaintenanceE.g. technical debt, packaging, etc.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions