Repository navigation
Description
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
inFlightchaining increateContentReporter - the Promise plumbing in
serializeDoc,serializeMerged,reportRecoveredand the peer-save re-check after serializing - the two "your editor cannot recover in-place" lock branches in
runStaleRecoveryandundoResyncWipe
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, theonStatelesshook, the grant hand-off inonDisconnectandafterUnloadDocument - client: the
SEED_*messages,pendingSeedGrant,requestSeedGrant,abandonSeedGrant,onSeedMessage,seedOnUnsolicitedGrantand the re-request inonSynced - the
hydratedmeta key,hasLocalOnlyContentand thesessionNoncerebuild, 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: theconnection.context?.userfallbacks inconnectedandbeforeHandleAwarenessare unnecessary, since Hocuspocus already passes the connection's context ascontext.onRequestcheckslifecycle.isReady() && !lifecycle.isShuttingDown(), butonShutdownStartalready setsisServerReadyto false.services/yjs/src/lib/shutdown.ts(80 lines plus tests) could be about 15 lines inserver.ts.dev/docs/yjs.mdshowshydratereturningvoid | Promise<void>, whiletypes.tsrequires it to be synchronous.- The comment on
YjsDeniedReasoninuseYjsSession.tspoints toservices/yjs/src/server.ts; the enum lives inservices/yjs/src/lib/errors.ts.
Activity