Skip to content

Commit 267d552

Browse files
zxch3nclaude
andauthored
feat(core)!: add ephemeral sync and replace direction with source (#77)
* feat(core): add setStateWithEphemeralPatch for temporary real-time sync Combine LoroDoc with EphemeralStore to sync temporary state changes (e.g. drag/scale operations) without polluting editing history. Ephemeral patches are automatically finalized to LoroDoc after a debounced timeout. - Add setStateWithEphemeralPatch() and finalizeEphemeralPatches() to Mirror - Add SyncDirection.FROM_EPHEMERAL for remote ephemeral patch events - Extract EphemeralPatchManager into separate ephemeral.ts module - Add 16 tests covering ephemeral patch lifecycle Closes #35 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(core): add drag-and-drop simulation and debounce tests Add e2e-style tests simulating real drag/resize scenarios: - Full drag lifecycle (start → rapid moves → end via timeout) - Rapid 50-frame drag keeping LoroDoc clean until finalize - Manual finalize on mouseup before timeout - Multi-element simultaneous drag - Sequential drags (drag, release, drag again) - Debounce timer reset verification - Default timeout behavior - Resize simulation (width+height) - Dispose cancels pending finalize timer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add ephemeral patch usage docs and JSDoc - Add detailed JSDoc on setStateWithEphemeralPatch, finalizeEphemeralPatches, ephemeralStore option, and SyncDirection.FROM_EPHEMERAL - Add "Ephemeral Patches" section to packages/core/README.md with setup, routing rules table, usage examples, finalization behavior, and subscriber direction - Add ephemeral patch summary and routing table to top-level README.md - Update SyncDirection and Mirror method listings in both READMEs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(core): address PR review — ephemeral isolation and $cid preservation Fix three issues identified in code review: 1. Remote ephemeral values leaking into setState diffs: updateLoro now diffs against this.state (composed) instead of this.baseState, so only the user's actual changes are captured when remote ephemeral overlay values are present. 2. baseState contaminated with ephemeral values after mixed updates: Both setState and setStateWithEphemeralPatch now rebuild baseState from LoroDoc snapshot instead of newState (which may contain ephemeral overlay values from the composed state). 3. $cid lost during compose cloning: Object spread drops non-enumerable properties. compose() now explicitly copies $cid after cloning map objects along the path. Add 4 regression tests for these fixes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: replace `as any` casts in ephemeral.ts with proper EphemeralValue type Extracts the value type from EphemeralStore.set() signature using Parameters utility type, satisfying AGENTS.md "no any" rule. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add comprehensive ephemeral routing and flush tests Adds 14 new tests covering: - Routing correctness: number/string/boolean/null on existing Map keys → Eph - Routing correctness: list push/delete, LoroText changes → LoroDoc - Mixed operations: ephemeral + structural in same call - Flush (finalizeEphemeralPatches): multi-field, multi-container, idempotent, clean state after flush Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: boost ephemeral.ts coverage to 98% statements, 94% branches Add 29 new edge-case tests for EphemeralPatchManager: - isEligible: reject delete kind, empty/missing container, missing key, object/array values, non-existent keys, List containers, invalid IDs - compose: empty store, null fields, unresolvable paths, navigation failure, values-already-match skip, array path segments via tree walk - finalize: empty local patches, externally-cleared store, partial key cleanup - resolvePath: Map parent walk, List parent walk, orphan container - subscribe/unsubscribe forwarding - scheduleFinalizeAfter/clearTimer behavior - dispose cleanup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(test): fix dead assertion and consolidate redundant tests - Fix dispose test: actually wire callbackFired to the scheduled callback so the assertion is meaningful - Merge redundant "object value" and "array value" isEligible tests into one (same typeof branch) - Remove unused destructured variables Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: fix README alignment with code - Close unclosed code fence before Ephemeral Patches section - Add missing origin, timestamp, message to SetStateOptions docs - Add FROM_EPHEMERAL to core README subscription direction list Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: fix broken markdown in react and jotai READMEs - React: close code fences before prose and $cid heading sections - Jotai: fix state.todos → todos variable reference in example Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(react,jotai): add ephemeral patch support React: - Add ephemeralStore option to useLoroStore and LoroProvider - Return setStateWithEphemeralPatch and finalizeEphemeralPatches from useLoroStore - Add useLoroEphemeralAction and useLoroFinalizeEphemeral hooks to createLoroContext Jotai: - Add ephemeralStore option to LoroMirrorAtomConfig - Add loroMirrorAtoms() returning { stateAtom, ephemeralAtom, finalizeAtom } - Forward FROM_EPHEMERAL direction in subscriber - Keep loroMirrorAtom() backward-compatible (single atom return) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(core): prevent remote ephemeral leak and Ignore field loss Bug 1: setStateWithEphemeralPatch diffed against baseState (doc-only), causing pre-existing remote ephemeral values to be re-emitted as local changes and permanently committed on finalize. Now diffs against the composed state so only actual user changes are detected. Bug 2: When ephemeralStore is enabled, setState rebuilt baseState from buildRootStateSnapshot() which drops schema.Ignore() fields (they are memory-only). Now preserves Ignore fields from the previous state. Adds regression tests for both issues. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs(core): add ephemeral computation model and compatibility note Explain how MirrorState is composed from LoroDoc + EphemeralStore, how changes are classified, and that peers without EphemeralStore sync normally via LoroDoc. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(core): compare baseState in checkStateConsistency checkStateConsistency was comparing this.state (which includes the ephemeral overlay) against a doc-only snapshot, causing false failures whenever ephemeral patches were active. Now compares this.baseState which is doc-only + Ignore fields. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(core): remove containerPaths cache from EphemeralPatchManager The cache was unnecessary (paths can be resolved fresh from the doc) and incorrect when list items are moved (cached indices become stale). - Remove containerPaths Map and all references - resolvePath now always computes fresh from doc.getContainerById - writeChanges no longer takes PathResolverContext Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * perf(core): use deadline-based debounce instead of clearTimeout+setTimeout At 60fps, repeatedly calling clearTimeout+setTimeout is expensive. Now scheduleFinalizeAfter only pushes out a debounceUntil deadline; the existing timer callback re-schedules itself for the remaining time if the deadline hasn't been reached yet. Only one setTimeout is created initially and at most one re-schedule per timer fire. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(core): extract DebounceTimer into its own module with unit tests Extract the deadline-based debounce logic from EphemeralPatchManager into a standalone DebounceTimer class. This makes the timer logic independently testable and reusable. 10 unit tests covering: basic fire, default delay, postponement, no duplicate timers on rapid calls, callback update, clear, re-use, pending state, and multiple postponements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: unify setState with ephemeral routing, remove setStateWithEphemeralPatch When `ephemeralStore` is configured, `setState` now automatically classifies each change and routes eligible ones (primitive values on existing Map keys) to EphemeralStore. This eliminates the separate `setStateWithEphemeralPatch` API and prevents accidental ephemeral flush when mixing the two methods. - Merge ephemeral routing logic into `setState`, delete `setStateWithEphemeralPatch` - Add `finalizeTimeout` to `SetStateOptions` - Add `rebuildBaseState()` helper to consolidate type casts - Only schedule finalization timer when ephemeral changes exist - Remove `ephemeralAtom` from Jotai, `useLoroEphemeralAction` from React - Update all tests (49 call sites) and fix test expectations - Rewrite JSDoc and READMEs to clearly document the behavior change Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(core): use getPathToContainer and improve type safety in ephemeral - Replace custom resolvePath (60-line container tree walk) with LoroDoc.getPathToContainer one-liner - Add EphemeralEligibleChange type and make isEligible a type guard, eliminating `as ContainerID` casts in ephemeral write paths - Simplify PathResolverContext to just { doc: LoroDoc } - Use local Obj type alias in compose to reduce `as unknown as` casts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: rm plan file * fix: resolve lint violations * perf(core): optimize ephemeral hot paths * docs(core): clarify sync direction docs * feat(core)!: remove SyncDirection metadata * refactor(core)!: remove unused INITIAL update source * docs: hide patchEphemeral from public API docs * refactor(core): make patchEphemeral private --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e4b060d commit 267d552

27 files changed

Lines changed: 3876 additions & 381 deletions

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@
4444
- `Mirror(options: MirrorOptions<S>)`
4545
- `doc` (required), `schema?`, `initialState?`, `validateUpdates?`, `throwOnValidationError?`, `debug?`, `checkStateConsistency?`, `inferOptions?`.
4646
- Methods: `getState()`, `setState(updater, options?)`, `subscribe(cb)`, `dispose()`, `checkStateConsistency()`, `getContainerIds()`.
47-
- `SetStateOptions` supports `{ tags?: string | string[] }`; subscriber metadata includes `{ direction: SyncDirection; tags?: string[] }`.
47+
- `SetStateOptions` supports `{ tags?: string | string[] }`; subscriber metadata includes `{ source: UpdateSource; tags?: string[] }`.
4848
- `schema(definition, options?)` plus builders: `.String()`, `.Number()`, `.Boolean()`, `.Ignore()`, `.LoroMap()`, `.LoroMapRecord()`, `.LoroList()`, `.LoroMovableList()`, `.LoroText()`, `.LoroTree()`.
4949
- Runtime helpers from the schema module: `validateSchema`, `getDefaultValue`, `createValueFromSchema`, and type guards such as `isContainerSchema`, `isLoroMapSchema`, `isLoroListSchema`, `isLoroMovableListSchema`, `isLoroTextSchema`, `isLoroTreeSchema`, `isRootSchemaType`, `isListLikeSchema`.
50-
- Types re-exported at the root: `MirrorOptions`, `SetStateOptions`, `UpdateMetadata`, `InferType`, `InferInputType`, `InferContainerOptions`, `SchemaType`, `ContainerSchemaType`, `RootSchemaType`, `LoroMapSchema`, `LoroListSchema`, `LoroMovableListSchema`, `LoroTextSchemaType`, `LoroTreeSchema`, `SchemaOptions`, `ChangeKinds`, `MapChangeKinds`, `ListChangeKinds`, `MovableListChangeKinds`, `TreeChangeKinds`, `TextChangeKinds`, `SubscriberCallback`, `SyncDirection`.
50+
- Types re-exported at the root: `MirrorOptions`, `SetStateOptions`, `UpdateMetadata`, `InferType`, `InferInputType`, `InferContainerOptions`, `SchemaType`, `ContainerSchemaType`, `RootSchemaType`, `LoroMapSchema`, `LoroListSchema`, `LoroMovableListSchema`, `LoroTextSchemaType`, `LoroTreeSchema`, `SchemaOptions`, `ChangeKinds`, `MapChangeKinds`, `ListChangeKinds`, `MovableListChangeKinds`, `TreeChangeKinds`, `TextChangeKinds`, `SubscriberCallback`, `UpdateSource`.
5151
- Utilities: `toNormalizedJson(doc)` for tree normalization. `$cid` is a reserved property injected into mirrored map values but there is no exported constant.

README.md

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -365,15 +365,16 @@ const mySchema = schema({ outline: schema.LoroTree(node) });
365365
- **`validateUpdates`**: boolean (default `true`) – validate new state against schema.
366366
- **`throwOnValidationError`**: boolean (default `false`) – throw on invalid updates.
367367
- **`debug`**: boolean (default `false`) – log diffs and applied changes.
368-
- **`checkStateConsistency`**: boolean (default `false`) – after each `setState`, assert the in-memory state equals the normalized `LoroDoc` snapshot.
368+
- **`checkStateConsistency`**: boolean (default `false`) – after non-ephemeral `setState` calls, assert the doc-backed base state equals the normalized `LoroDoc` snapshot.
369369
- **`inferOptions`**: `{ defaultLoroText?: boolean; defaultMovableList?: boolean }` – influence container-type inference when inserting containers from plain values.
370370

371371
- `getState(): State`: Returns the current in-memory state view.
372-
- `setState(updater, options?)`: Update state and sync to Loro. Runs synchronously.
372+
- `setState(updater, options?)`: Update state and sync to Loro. Runs synchronously. When `ephemeralStore` is configured, eligible changes are automatically routed through EphemeralStore. See [Ephemeral Patches](#ephemeral-patches).
373373
- **`updater`**: either a partial object to shallow-merge or a function that may mutate a draft (Immer-style) or return a new state object.
374-
- **`options`**: `{ tags?: string | string[] }` – arbitrary tags attached to this update; delivered to subscribers in metadata.
374+
- **`options`**: `{ tags?: string | string[]; finalizeTimeout?: number }` – tags are delivered to subscribers in metadata; `finalizeTimeout` (default 50 000 ms) controls the debounce delay before ephemeral values auto-commit.
375+
- `finalizeEphemeralPatches()`: Immediately commit pending ephemeral patches to LoroDoc (e.g. on `mouseup`).
375376
- `subscribe(callback): () => void`: Subscribe to state changes. `callback` receives `(state, metadata)` where `metadata` includes:
376-
- **`direction`**: `SyncDirection``FROM_LORO` when changes came from the doc, `TO_LORO` when produced locally, `BIDIRECTIONAL` for manual/initial syncs.
377+
- **`source`**: `UpdateSource``LORO` when changes came from the doc, `MIRROR` when a local Mirror API changed state, `EPHEMERAL` when state changed due to an EphemeralStore update.
377378
- **`tags`**: `string[] | undefined` – tags provided via `setState`.
378379
- `dispose()`: Unsubscribe internal listeners and clear subscribers.
379380
- `checkStateConsistency()`: Manually trigger the consistency assertion described above.
@@ -383,22 +384,22 @@ const mySchema = schema({ outline: schema.LoroTree(node) });
383384

384385
- **Lists and IDs**: If your list schema provides an `idSelector`, list updates use minimal add/remove/update/move operations; otherwise index-based diffs are applied.
385386
- **Container inference**: When schema is missing/ambiguous for a field, the mirror infers container types from values. `inferOptions.defaultLoroText` makes strings become `LoroText`; `inferOptions.defaultMovableList` makes arrays become `LoroMovableList`.
386-
- **Consistency checks**: Enabling `checkStateConsistency` (or calling the method directly) is useful while developing or writing tests; it throws if Mirror state diverges from the normalized document snapshot.
387+
- **Consistency checks**: Enabling `checkStateConsistency` is useful while developing or writing tests; it auto-checks only non-ephemeral `setState` calls. Calling `checkStateConsistency()` manually compares the doc-backed base state against the normalized document snapshot, so active ephemeral overlay values are intentionally ignored.
387388

388389
### Types
389390

390-
- `SyncDirection`:
391-
- `FROM_LORO` – applied due to incoming `LoroDoc` changes
392-
- `TO_LORO` – applied due to local `setState`
393-
- `BIDIRECTIONAL`initial/manual sync context
394-
- `UpdateMetadata`: `{ direction: SyncDirection; tags?: string[] }`
395-
- `SetStateOptions`: `{ tags?: string | string[] }`
391+
- `UpdateSource`:
392+
- `LORO` – applied due to incoming `LoroDoc` changes
393+
- `MIRROR` – applied due to a local Mirror API call such as `setState` or `finalizeEphemeralPatches`
394+
- `EPHEMERAL`state recomposed due to an EphemeralStore change
395+
- `UpdateMetadata`: `{ source: UpdateSource; tags?: string[] }`
396+
- `SetStateOptions`: `{ tags?: string | string[]; origin?: string; timestamp?: number; message?: string; finalizeTimeout?: number }`
396397

397398
### Example
398399

399400
```ts
400401
import { LoroDoc } from "loro-crdt";
401-
import { Mirror, schema, SyncDirection } from "loro-mirror";
402+
import { Mirror, schema, UpdateSource } from "loro-mirror";
402403

403404
const todoSchema = schema({
404405
todos: schema.LoroList(
@@ -414,8 +415,8 @@ const doc = new LoroDoc();
414415
const mirror = new Mirror({ doc, schema: todoSchema, validateUpdates: true });
415416

416417
// Subscribe with metadata
417-
const unsubscribe = mirror.subscribe((state, { direction, tags }) => {
418-
if (direction === SyncDirection.FROM_LORO) {
418+
const unsubscribe = mirror.subscribe((state, { source, tags }) => {
419+
if (source === UpdateSource.LORO) {
419420
console.log("Remote update", tags);
420421
} else {
421422
console.log("Local update", tags);
@@ -432,6 +433,41 @@ mirror.setState(
432433
},
433434
{ tags: ["ui:add"] },
434435
);
436+
```
437+
438+
### Ephemeral Patches
439+
440+
For high-frequency temporary changes (dragging, resizing, scrubbing), pass an `ephemeralStore` to `MirrorOptions`. This changes how `setState` works: eligible changes (primitive values on existing Map keys) are automatically routed to the EphemeralStore for real-time sync without creating LoroDoc editing history. No separate API is needed — the same `setState` handles both persistent and ephemeral updates.
441+
442+
```ts
443+
import { LoroDoc, EphemeralStore } from "loro-crdt";
444+
445+
const doc = new LoroDoc();
446+
const eph = new EphemeralStore();
447+
const mirror = new Mirror({ doc, schema: mySchema, ephemeralStore: eph });
448+
449+
// Network sync for ephemeral state (your responsibility)
450+
eph.subscribeLocalUpdates((bytes) => channel.send(bytes));
451+
channel.on("ephemeral", (bytes) => eph.apply(bytes));
452+
453+
// During drag — x/y are primitives on existing keys → EphemeralStore
454+
mirror.setState(
455+
(s) => { s.items[0].x = e.clientX; s.items[0].y = e.clientY; },
456+
{ finalizeTimeout: 1_000 }, // auto-commit after 1s of inactivity
457+
);
458+
459+
// On mouseup — commit ephemeral values to LoroDoc immediately
460+
mirror.finalizeEphemeralPatches();
461+
```
462+
463+
**Routing rules** (with `ephemeralStore` configured):
464+
465+
| Change type | Destination |
466+
|---|---|
467+
| Primitive value on an existing key of an existing Map | `EphemeralStore` |
468+
| New Map / new key / container value / List·Text·Tree ops | `LoroDoc` directly |
469+
470+
Without `ephemeralStore`, all changes go to LoroDoc as usual. See the [core package README](./packages/core/README.md#ephemeral-patches) for full details.
435471

436472
### How `$cid` Works
437473

0 commit comments

Comments
 (0)