Problem
When using sync(doc).addEphemeral('cursors', cursorStore) to register a CursorEphemeralStore for LoroEphemeralCursorPlugin, there is a race condition with EphemeralStoreManager.applyRemote().
Root Cause
applyRemote() calls getOrCreate(docId, namespace) which auto-creates a TimerlessEphemeralStore if no store exists for that namespace. Once this auto-created store exists, addEphemeral() / registerExternal() throws "Ephemeral store already exists", permanently blocking the CursorEphemeralStore from being registered.
This happens deterministically when:
- Browser A registers a
CursorEphemeralStore and sends cursor data
- Browser B connects — the sync handshake includes Browser A's cursor ephemeral data
- Browser B's
applyRemote() runs and auto-creates a TimerlessEphemeralStore for the 'cursors' namespace
- Browser B's component mounts and calls
addEphemeral('cursors', cursorStore) — throws
The CursorEphemeralStore can never be registered on Browser B, so LoroEphemeralCursorPlugin is never added to the editor. Cursor presence fails.
Why it only affects external stores
DECLARED ephemeral stores (passed as the 3rd arg to repo.get()) work perfectly because SyncRefImpl pre-creates the stores via getOrCreateNamespacedStore() in its constructor — before any sync handshake. Canvas presence uses this pattern and works reliably.
External stores (registered via addEphemeral()) are vulnerable because there's always a window between repo.get() (which starts sync) and addEphemeral() (called later, often from a React component).
Proposed Fix
In EphemeralStoreManager.registerExternal(), if the existing store was auto-created (is a TimerlessEphemeralStore), replace it instead of throwing:
registerExternal(docId, namespace, store) {
// ... existing setup ...
const existing = namespaceStores.get(namespace);
if (existing) {
if (existing instanceof TimerlessEphemeralStore) {
// Auto-created by applyRemote — safe to replace
const data = existing.encodeAll();
if (data.length > 0) store.apply(data); // Migrate accumulated data
const unsub = this.#subscriptions.get(existing);
unsub?.();
this.#subscriptions.delete(existing);
namespaceStores.set(namespace, store);
this.#subscribeToStore(docId, namespace, store);
return;
}
throw new Error(`Ephemeral store "${namespace}" already exists`);
}
namespaceStores.set(namespace, store);
this.#subscribeToStore(docId, namespace, store);
}
This makes addEphemeral idempotent for auto-created stores while still throwing for genuine conflicts (two external stores competing for the same namespace).
Secondary Issue: encodeAll() doesn't touch external stores
EphemeralStoreManager.encodeAll() only calls touch() on TimerlessEphemeralStore instances. External stores (like CursorEphemeralStore) skip the touch, causing their data to expire (30s default timeout) during heartbeat-based sync. The real-time broadcast path works, but catch-up (sync-response, heartbeat) silently drops stale data.
Environment
@loro-extended/repo@6.0.0-beta.0
loro-prosemirror@0.4.2
loro-crdt@1.10.6
Workaround
We're currently calling repo.synchronizer.registerExternalStore(docId, namespace, store) BEFORE repo.get() to ensure the store is in the map before sync starts. This works but depends on the internal synchronizer getter.
Problem
When using
sync(doc).addEphemeral('cursors', cursorStore)to register aCursorEphemeralStoreforLoroEphemeralCursorPlugin, there is a race condition withEphemeralStoreManager.applyRemote().Root Cause
applyRemote()callsgetOrCreate(docId, namespace)which auto-creates aTimerlessEphemeralStoreif no store exists for that namespace. Once this auto-created store exists,addEphemeral()/registerExternal()throws"Ephemeral store already exists", permanently blocking theCursorEphemeralStorefrom being registered.This happens deterministically when:
CursorEphemeralStoreand sends cursor dataapplyRemote()runs and auto-creates aTimerlessEphemeralStorefor the 'cursors' namespaceaddEphemeral('cursors', cursorStore)— throwsThe
CursorEphemeralStorecan never be registered on Browser B, soLoroEphemeralCursorPluginis never added to the editor. Cursor presence fails.Why it only affects external stores
DECLARED ephemeral stores (passed as the 3rd arg to
repo.get()) work perfectly becauseSyncRefImplpre-creates the stores viagetOrCreateNamespacedStore()in its constructor — before any sync handshake. Canvas presence uses this pattern and works reliably.External stores (registered via
addEphemeral()) are vulnerable because there's always a window betweenrepo.get()(which starts sync) andaddEphemeral()(called later, often from a React component).Proposed Fix
In
EphemeralStoreManager.registerExternal(), if the existing store was auto-created (is aTimerlessEphemeralStore), replace it instead of throwing:This makes
addEphemeralidempotent for auto-created stores while still throwing for genuine conflicts (two external stores competing for the same namespace).Secondary Issue:
encodeAll()doesn't touch external storesEphemeralStoreManager.encodeAll()only callstouch()onTimerlessEphemeralStoreinstances. External stores (likeCursorEphemeralStore) skip the touch, causing their data to expire (30s default timeout) during heartbeat-based sync. The real-time broadcast path works, but catch-up (sync-response, heartbeat) silently drops stale data.Environment
@loro-extended/repo@6.0.0-beta.0loro-prosemirror@0.4.2loro-crdt@1.10.6Workaround
We're currently calling
repo.synchronizer.registerExternalStore(docId, namespace, store)BEFORErepo.get()to ensure the store is in the map before sync starts. This works but depends on the internalsynchronizergetter.