Skip to content

Commit 370f6f0

Browse files
lodystage[bot]zxch3nclaude
authored
feat: support mergeable map child containers (#85)
* feat(core): support mergeable map child containers * fix(core): harden mergeable map child updates * test(core): cover mergeable child container variants * fix(core): keep container registration warnings debug-only * refactor(core): avoid detached containers for mergeable map inserts * docs(core): explain mergeable map child containers Document the problem mergeable map child containers solve (concurrent child creation under the same Map key being dropped by last-writer-wins), what enabling the option does (logical-position-derived identity so peers converge on one CRDT object), and how to configure it. Adds a usage example to the core README and expands the JSDoc on both `SchemaOptions` and `InferContainerOptions`, with links to the Loro blog. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(core): resolve mergeable child containers in toNormalizedJson toNormalizedJson built each map via LoroMap.getShallowValue(), which leaks mergeable child containers as raw binary markers instead of resolving them. As a result the public helper (and the schema-less buildRootStateSnapshot fallback) returned garbage (e.g. {0:0,1:76,...}) for any mergeable Map/List/ Text/MovableList/Tree child, even though getState() and doc.toJSON() were correct. Resolve containers explicitly via get()/toJSON() so both regular and mergeable children are normalized uniformly with $cid injected on every map. Because toJsonWithReplacer re-resolves mergeable container-id strings found in the returned structure (corrupting nested $cid), inject $cid behind a sentinel prefix during the pass and strip it in restoreCidDescriptors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(deps): require loro-crdt >=1.13.3 for mergeable containers loro-crdt 1.13.2 panics (unreachable at container_store/container_wrapper.rs) when importing an out-of-order op on a mergeable child container; fixed upstream in 1.13.3. Raise the peer dependency floor across core/react/jotai and update the docs/JSDoc version requirement accordingly so the mergeable feature only runs against a loro-crdt build that has the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: refresh pnpm lockfile * test(core): fix type errors in mergeable toNormalizedJson test vitest typecheck (CI) flagged the object-literal setState calls: the record value omitted a required field and the tree node omitted its required `id`. Drop the unused optional field from the schema and add the tree node id so the test type-checks while exercising the same runtime behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Zixuan Chen <me@zxch3n.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7a521bc commit 370f6f0

12 files changed

Lines changed: 1282 additions & 351 deletions

README.md

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,10 @@ Loro Mirror provides a declarative schema system that enables:
166166
- `schema.LoroMap(definition, options?)` - Object container that can nest arbitrary field schemas
167167
- Supports dynamic key-value definition with `catchall`: `schema.LoroMap({...}).catchall(valueSchema)`
168168
- Always injects a read-only `$cid` field in mirrored state equal to the underlying Loro container id. Applies uniformly to root maps, nested maps, list items, and tree node `data` maps.
169+
- `options.mergeableMapChildContainers?: boolean` makes new direct child containers under this Map use Loro's mergeable container APIs.
169170
- `schema.LoroMapRecord(valueSchema, options?)` - Equivalent to `LoroMap({}).catchall(valueSchema)` for homogeneous maps
170171
- Entriesmirrored state always include `$cid`.
172+
- `options.mergeableMapChildContainers?: boolean` applies to dynamic entries in the record.
171173
- `schema.LoroList(itemSchema, idSelector?, options?)` - Ordered list container
172174
- Providing an `idSelector` (e.g., `(item) => item.id`) enables minimal add/remove/update/move diffs
173175
- `schema.LoroMovableList(itemSchema, idSelector, options?)` - List with native move operations, requires an `idSelector`
@@ -409,7 +411,7 @@ const mySchema = schema({ outline: schema.LoroTree(node) });
409411
- **`validateUpdates`**: boolean (default `true`) – validate new state against schema.
410412
- **`debug`**: boolean (default `false`) – log diffs and applied changes.
411413
- **`checkStateConsistency`**: boolean (default `false`) – after non-ephemeral `setState` calls, assert the doc-backed base state equals the normalized `LoroDoc` snapshot.
412-
- **`inferOptions`**: `{ defaultLoroText?: boolean; defaultMovableList?: boolean }`influence container-type inference when inserting containers from plain values.
414+
- **`inferOptions`**: `{ defaultLoroText?: boolean; defaultMovableList?: boolean; mergeableMapChildContainers?: boolean }`influence container-type inference and Map child-container creation.
413415

414416
- `getState(): State`: Returns the current in-memory state view.
415417
- `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).
@@ -427,6 +429,7 @@ const mySchema = schema({ outline: schema.LoroTree(node) });
427429
428430
- **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.
429431
- **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`.
432+
- **Mergeable Map child containers**: By default, when two peers concurrently create a child container under the _same_ Map key, each peer's child gets a distinct container ID; the Map slot is last-writer-wins, so one peer's child (and everything written into it) is silently dropped after sync. Enabling this makes the child's identity derive from its logical position (parent container id + key + type), so every peer resolves to the same CRDT object and their content merges. Configure it on the parent Map via `schema.LoroMap(..., { mergeableMapChildContainers: true })` or `schema.LoroMapRecord(..., { mergeableMapChildContainers: true })`; `inferOptions.mergeableMapChildContainers` is the schema-less/global fallback. The default is `false` for backward-compatible `setContainer` document format, and existing same-type child containers are reused rather than migrated or overwritten. All collaborating clients must use `loro-crdt >= 1.13.3` to interpret mergeable child markers. Concurrent first writes merge CRDT content rather than behaving like LWW replacement, so identical concurrent text/list writes can appear twice. Avoid very deep chains of mergeable child maps because mergeable container IDs encode the logical path and grow with depth. See <https://loro.dev/blog/mergeable-containers> for the underlying model.
430433
- **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.
431434
432435
### Types
@@ -476,6 +479,10 @@ mirror.setState(
476479
},
477480
{ tags: ["ui:add"] },
478481
);
482+
483+
// Cleanup
484+
unsubscribe();
485+
mirror.dispose();
479486
```
480487
481488
### Ephemeral Patches
@@ -495,7 +502,10 @@ channel.on("ephemeral", (bytes) => eph.apply(bytes));
495502

496503
// During drag — x/y are primitives on existing keys → EphemeralStore
497504
mirror.setState(
498-
(s) => { s.items[0].x = e.clientX; s.items[0].y = e.clientY; },
505+
(s) => {
506+
s.items[0].x = e.clientX;
507+
s.items[0].y = e.clientY;
508+
},
499509
{ finalizeTimeout: 1_000 }, // auto-commit after 1s of inactivity
500510
);
501511

@@ -505,9 +515,9 @@ mirror.finalizeEphemeralPatches();
505515
506516
**Routing rules** (with `ephemeralStore` configured):
507517
508-
| Change type | Destination |
509-
|---|---|
510-
| Primitive value on an existing key of an existing Map | `EphemeralStore` |
518+
| Change type | Destination |
519+
| -------------------------------------------------------- | ------------------ |
520+
| Primitive value on an existing key of an existing Map | `EphemeralStore` |
511521
| New Map / new key / container value / List·Text·Tree ops | `LoroDoc` directly |
512522
513523
Without `ephemeralStore`, all changes go to LoroDoc as usual. See the [core package README](./packages/core/README.md#ephemeral-patches) for full details.
@@ -518,11 +528,6 @@ Without `ephemeralStore`, all changes go to LoroDoc as usual. See the [core pack
518528
- For any `schema.LoroMap(...)`, Mirror injects a read-only `$cid` field into the mirrored state that equals the underlying Loro container ID.
519529
- `$cid` lives only in the app state and is never written back to the document. Mirror uses it for efficient diffs; you can use it as a stable list selector: `schema.LoroList(item, (x) => x.$cid)`.
520530
521-
// Cleanup
522-
unsubscribe();
523-
mirror.dispose();
524-
```
525-
526531
## License
527532
528533
MIT

packages/core/README.md

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ Trees are advanced usage; see Advanced: Trees at the end.
7777
- validateUpdates: Validate on `setState`
7878
- debug: Verbose logging
7979
- checkStateConsistency: Extra runtime check that the doc-backed base state still matches `toNormalizedJson(doc)` after non-ephemeral `setState` updates
80-
- inferOptions: `{ defaultLoroText?: boolean; defaultMovableList?: boolean }` for container inference when schema is missing
80+
- inferOptions: `{ defaultLoroText?: boolean; defaultMovableList?: boolean; mergeableMapChildContainers?: boolean }` for container inference and Map child-container creation
8181
- Methods:
8282
- getState(): Current state
8383
- setState(updater | partial, options?): Mutate a draft or return a new object. Runs synchronously so downstream logic can immediately read the latest state. When `ephemeralStore` is configured, eligible changes are automatically routed through EphemeralStore. See [Ephemeral Patches](#ephemeral-patches) below.
@@ -112,13 +112,45 @@ Signatures:
112112
- `schema.LoroTree(nodeMapSchema, options?)`
113113

114114
SchemaOptions for any field: `{ required?: boolean; defaultValue?: unknown; description?: string; validate?: (value) => boolean | string }`.
115+
Map-only option: `{ mergeableMapChildContainers?: boolean }` on `schema.LoroMap` or `schema.LoroMapRecord`.
115116

116117
Any options:
117118

118119
- `schema.Any({ defaultLoroText?: boolean; defaultMovableList?: boolean })`
119120
- `defaultLoroText` defaults to `false` for Any when omitted (primitive string), overriding the global `inferOptions.defaultLoroText`.
120121
- `defaultMovableList` inherits from the global inference options unless specified.
121122

123+
Mergeable Map child containers:
124+
125+
By default, child containers are created with `LoroMap.setContainer`. If two peers concurrently create a child container under the _same_ Map key (e.g. both first-write `records.note`), each peer's child gets a **distinct** container ID. Because the Map slot is last-writer-wins, one peer's child — and everything written into it — is silently dropped after sync.
126+
127+
**Mergeable containers** fix this: the child's identity is derived from its _logical position_ (parent container id + key + type) instead of the creating operation, so every peer's `ensureMergeable*` call resolves to the **same** CRDT object and their content merges. See <https://loro.dev/blog/mergeable-containers> for the full model.
128+
129+
Enable it on the parent Map when multiple peers may create the same child for the first time concurrently:
130+
131+
```ts
132+
const appSchema = schema({
133+
records: schema.LoroMapRecord(
134+
schema.LoroMap({ entries: schema.LoroList(schema.String()) }),
135+
{ mergeableMapChildContainers: true }, // ← opt in on the parent map
136+
),
137+
});
138+
139+
// docA.setState({ records: { note: { entries: ["A"] } } }) and
140+
// docB.setState({ records: { note: { entries: ["B"] } } }) now converge to one
141+
// `note` map whose `entries` contains both "A" and "B" after sync — instead of
142+
// one peer's `note` clobbering the other's.
143+
```
144+
145+
- Prefer configuring the parent Map: `schema.LoroMap(..., { mergeableMapChildContainers: true })` or `schema.LoroMapRecord(..., { mergeableMapChildContainers: true })`.
146+
- `inferOptions.mergeableMapChildContainers` remains the schema-less/global fallback and defaults to `false` for backward-compatible document format and `LoroMap.setContainer` semantics.
147+
- When enabled for a parent Map, new direct child containers under that `LoroMap`'s keys are created with Loro's `ensureMergeableMap/List/MovableList/Text/Tree` APIs, so concurrent first creation at the same parent/key/type shares one logical child container.
148+
- Existing same-type child containers are reused as-is, including regular `setContainer` children from old data.
149+
- All collaborating clients must use `loro-crdt >= 1.13.3` to interpret mergeable child markers. Older clients preserve the data as ordinary binary values but do not expose the mergeable child semantics.
150+
- Concurrent first writes merge CRDT content rather than acting like LWW replacement. If two peers insert the same text or list item while offline, both writes can be visible after sync.
151+
- Avoid very deep chains of mergeable child maps because mergeable container IDs encode the logical path and grow with depth.
152+
- List items and tree nodes keep the existing `insertContainer`/node-data behavior because they are not identified by a stable Map key.
153+
122154
Reserved key `$cid`:
123155

124156
- `$cid` is injected into mirrored state for all `LoroMap` schemas; it is never written back to Loro and is ignored by diffs/updates. It’s useful as a stable identifier (e.g., `schema.LoroList(map, x => x.$cid)`).
@@ -155,11 +187,11 @@ A single `setState` call can write to both destinations in one invocation. `getS
155187

156188
### Routing rules
157189

158-
| Change type | Destination | Example |
159-
|---|---|---|
160-
| Primitive value (`string`, `number`, `boolean`, `null`) on an **existing key** of an **existing LoroMap** | `EphemeralStore` | `s.items[0].x = 100` |
161-
| New Map / new key / container value | `LoroDoc` | `s.items.push({...})` |
162-
| List / Text / Tree operations | `LoroDoc` | `s.items.splice(...)` |
190+
| Change type | Destination | Example |
191+
| --------------------------------------------------------------------------------------------------------- | ---------------- | --------------------- |
192+
| Primitive value (`string`, `number`, `boolean`, `null`) on an **existing key** of an **existing LoroMap** | `EphemeralStore` | `s.items[0].x = 100` |
193+
| New Map / new key / container value | `LoroDoc` | `s.items.push({...})` |
194+
| List / Text / Tree operations | `LoroDoc` | `s.items.splice(...)` |
163195

164196
The rule is intentionally conservative: only simple value-swaps on known keys go to EphemeralStore. Anything structural always goes to LoroDoc.
165197

@@ -200,7 +232,7 @@ mirror.setState(
200232
// Mixed: push goes to LoroDoc, x/y go to EphemeralStore (same call)
201233
mirror.setState((s) => {
202234
s.items.push({ x: 0, y: 0, name: "new" }); // → LoroDoc
203-
s.items[0].x = 999; // → EphemeralStore
235+
s.items[0].x = 999; // → EphemeralStore
204236
});
205237

206238
// On mouseup — commit ephemeral values to LoroDoc immediately

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"immer": "^10.0.3"
4747
},
4848
"peerDependencies": {
49-
"loro-crdt": "^1.8.0"
49+
"loro-crdt": "^1.13.3"
5050
},
5151
"devDependencies": {
5252
"@types/node": "^20.10.5",

0 commit comments

Comments
 (0)