Commit 267d552
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
File tree
- packages
- core
- scripts
- src
- core
- tests
- jotai
- src
- react
- src
- tests
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
47 | | - | |
| 47 | + | |
48 | 48 | | |
49 | 49 | | |
50 | | - | |
| 50 | + | |
51 | 51 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
365 | 365 | | |
366 | 366 | | |
367 | 367 | | |
368 | | - | |
| 368 | + | |
369 | 369 | | |
370 | 370 | | |
371 | 371 | | |
372 | | - | |
| 372 | + | |
373 | 373 | | |
374 | | - | |
| 374 | + | |
| 375 | + | |
375 | 376 | | |
376 | | - | |
| 377 | + | |
377 | 378 | | |
378 | 379 | | |
379 | 380 | | |
| |||
383 | 384 | | |
384 | 385 | | |
385 | 386 | | |
386 | | - | |
| 387 | + | |
387 | 388 | | |
388 | 389 | | |
389 | 390 | | |
390 | | - | |
391 | | - | |
392 | | - | |
393 | | - | |
394 | | - | |
395 | | - | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
396 | 397 | | |
397 | 398 | | |
398 | 399 | | |
399 | 400 | | |
400 | 401 | | |
401 | | - | |
| 402 | + | |
402 | 403 | | |
403 | 404 | | |
404 | 405 | | |
| |||
414 | 415 | | |
415 | 416 | | |
416 | 417 | | |
417 | | - | |
418 | | - | |
| 418 | + | |
| 419 | + | |
419 | 420 | | |
420 | 421 | | |
421 | 422 | | |
| |||
432 | 433 | | |
433 | 434 | | |
434 | 435 | | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
435 | 471 | | |
436 | 472 | | |
437 | 473 | | |
| |||
0 commit comments