Skip to content

Commit b031b09

Browse files
authored
feat(core): add withCid ($cid) support for maps and tree node.data (#19)
* > feat(core): add withCid ($cid) support for maps and tree node.data - Add CID_KEY constant ($cid) to identify container id in mirrored state - Types: extend InferType so LoroMap/LoroMapRecord with { withCid: true } infer { ...; $cid: string }; add tree node variant when nodeSchema has withCid - Mirror snapshot: register roots before snapshot; add containerToStateJson and buildRootStateSnapshot to normalize trees and inject $cid; use in initialize + checkStateConsistency - Events: extend applyEventBatchToState with optional options object { getContainerById, containerToJson, nodeDataWithCid, getNodeDataCid } (backward compatible with function arg); stamp $cid on tree “create” during event application; container inserts (map/list) go through containerToJson - Writes: ignore $cid in diffMap and map initialization/updates (read-only) - Tests: add packages/core/tests/cid.test.ts covering initial snapshot, event inserts, tree creates, and diff ignoring $cid (use doc.commit where needed) - Docs: update root README and packages/core/README.md to document withCid, $cid semantics, and examples (e.g., list idSelector via x => x.$cid) - Plan: update plan.md with completed progress and changelog Notes: - $cid exists only in mirrored state; it is never written to Loro - No behavior change for maps without { withCid: true } * fix: ensure cids can be assigned on new containers created by setState * refactor: simplify types * fix(core): robust bidirectional sync, cleanup debug, and generalize map updates * chore: fix lint * docs: refine docs * test: confirm got correct types
1 parent 2b48f76 commit b031b09

File tree

16 files changed

+1133
-272
lines changed

16 files changed

+1133
-272
lines changed

AGENTS.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,14 @@
1313
- Test all: `pnpm test` (Vitest across packages).
1414
- Lint all: `pnpm lint` (ESLint on `src` in each package).
1515
- Type check: `pnpm typecheck` (TS `--noEmit`).
16-
- Per-package examples:
17-
- Core build: `pnpm --filter loro-mirror build`
18-
- React tests (watch): `pnpm --filter loro-mirror-react test:watch`
1916

2017
## Coding Style & Naming Conventions
2118

2219
- Language: TypeScript (strict). React files use `.tsx`.
2320
- Formatting: Prettier (tabWidth 4). Keep imports ordered logically and avoid unused vars (underscore- prefix is ignored by lint).
2421
- Linting: ESLint with `@typescript-eslint`, `react`, and `react-hooks`. Run `pnpm lint` before pushing.
2522
- Structure: Export public APIs from each package’s `src/index.ts`. Keep tests mirroring source folder layout.
23+
- Do not use 'any' type in typescript
2624

2725
## Testing Guidelines
2826

README.md

Lines changed: 71 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,15 @@ import { schema, createStore } from "loro-mirror";
4040
// Define your schema
4141
const todoSchema = schema({
4242
todos: schema.LoroList(
43-
schema.LoroMap({
44-
id: schema.String(),
45-
text: schema.String(),
46-
completed: schema.Boolean({ defaultValue: false }),
47-
}),
43+
schema.LoroMap(
44+
{
45+
text: schema.String(),
46+
completed: schema.Boolean({ defaultValue: false }),
47+
},
48+
{ withCid: true },
49+
),
50+
// Use `$cid` (reuses Loro container id; explained later)
51+
(t) => t.$cid,
4852
),
4953
});
5054

@@ -63,7 +67,6 @@ store.setState((s) => ({
6367
todos: [
6468
...s.todos,
6569
{
66-
id: Date.now().toString(),
6770
text: "Learn Loro Mirror",
6871
completed: false,
6972
},
@@ -73,11 +76,11 @@ store.setState((s) => ({
7376
// Or: draft-style updates (mutate a draft)
7477
store.setState((state) => {
7578
state.todos.push({
76-
id: Date.now().toString(),
7779
text: "Learn Loro Mirror",
7880
completed: false,
7981
});
80-
// no return needed
82+
// `$cid` is injected automatically for withCid maps
83+
// and reuses the underlying Loro container id (explained later)
8184
});
8285

8386
// Subscribe to state changes
@@ -115,7 +118,9 @@ Loro Mirror provides a declarative schema system that enables:
115118
- **Container Types**:
116119
- `schema.LoroMap(definition, options?)` - Object container that can nest arbitrary field schemas
117120
- Supports dynamic key-value definition with `catchall`: `schema.LoroMap({...}).catchall(valueSchema)`
121+
- Supports `withCid: true` (in `options`) to inject 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.
118122
- `schema.LoroMapRecord(valueSchema, options?)` - Equivalent to `LoroMap({}).catchall(valueSchema)` for homogeneous maps
123+
- Also supports `withCid: true` to inject `$cid` into each record entry’s mirrored state.
119124
- `schema.LoroList(itemSchema, idSelector?, options?)` - Ordered list container
120125
- Providing an `idSelector` (e.g., `(item) => item.id`) enables minimal add/remove/update/move diffs
121126
- `schema.LoroMovableList(itemSchema, idSelector, options?)` - List with native move operations, requires an `idSelector`
@@ -130,23 +135,25 @@ import { schema } from "loro-mirror";
130135

131136
type UserId = string & { __brand: "userId" };
132137
const appSchema = schema({
133-
user: schema.LoroMap({
134-
id: schema.String<UserId>(),
135-
name: schema.String(),
136-
age: schema.Number({ required: false }),
137-
}),
138+
user: schema.LoroMap(
139+
{
140+
name: schema.String(),
141+
age: schema.Number({ required: false }),
142+
},
143+
{ withCid: true },
144+
),
138145
tags: schema.LoroList(schema.String()),
139146
});
140147

141148
// Inferred state type:
142149
// type AppState = {
143-
// user: { id: UserId; name: string; age: number | undefined };
150+
// user: { $cid: string; name: string; age: number | undefined };
144151
// tags: string[];
145152
// }
146153
type AppState = InferType<typeof appSchema>;
147154
```
148155

149-
> **Note**: If you need optional custom string types like `{ id?: UserId }`, you currently need to explicitly define it as `schema.String<UserId>({ required: false })`
156+
> **Note**: If you need optional custom string types with generics (e.g., `{ status?: Status }`), explicitly define them as `schema.String<Status>({ required: false })`.
150157
151158
For `LoroMap` with dynamic key-value pairs:
152159

@@ -162,6 +169,16 @@ const record = schema.LoroMapRecord(schema.Boolean());
162169

163170
When a field has `required: false`, the corresponding type becomes optional (union with `undefined`).
164171

172+
With `withCid: true` on a `LoroMap` or `LoroMapRecord`, the inferred type gains a `$cid: string` field. For example:
173+
174+
```ts
175+
const user = schema.LoroMap({ name: schema.String() }, { withCid: true });
176+
// InferType<typeof user> => { name: string; $cid: string }
177+
178+
// In lists, `$cid` is handy as a stable idSelector:
179+
const users = schema.LoroList(user, (x) => x.$cid);
180+
```
181+
165182
#### Default Values & Creation
166183

167184
- Explicitly specified `defaultValue` takes the highest precedence.
@@ -194,12 +211,14 @@ const result = validateSchema(appSchema, {
194211
```ts
195212
const todoSchema = schema({
196213
todos: schema.LoroMovableList(
197-
schema.LoroMap({
198-
id: schema.String(),
199-
text: schema.String(),
200-
completed: schema.Boolean({ defaultValue: false }),
201-
}),
202-
(t) => t.id,
214+
schema.LoroMap(
215+
{
216+
text: schema.String(),
217+
completed: schema.Boolean({ defaultValue: false }),
218+
},
219+
{ withCid: true },
220+
),
221+
(t) => t.$cid, // stable id from Loro container id ($cid)
203222
),
204223
});
205224
```
@@ -208,6 +227,10 @@ const todoSchema = schema({
208227

209228
- Fields defined with `schema.Ignore()` won't sync with Loro, commonly used for derived/cached fields. Runtime validation always passes for these fields.
210229

230+
#### Reserved Field: `$cid`
231+
232+
- `$cid` is a reserved, read-only field injected into mirrored state for maps with `withCid: true`. It is never written back to Loro and will be ignored by diffs and updates. Use it as a stable identifier where helpful (e.g., list `idSelector`).
233+
211234
### React Usage
212235

213236
```tsx
@@ -216,14 +239,17 @@ import { LoroDoc } from "loro-crdt";
216239
import { schema } from "loro-mirror";
217240
import { createLoroContext } from "loro-mirror-react";
218241

219-
// Define your schema
242+
// Define your schema (use `$cid` from withCid maps)
220243
const todoSchema = schema({
221244
todos: schema.LoroList(
222-
schema.LoroMap({
223-
id: schema.String({ required: true }),
224-
text: schema.String({ required: true }),
225-
completed: schema.Boolean({ defaultValue: false }),
226-
}),
245+
schema.LoroMap(
246+
{
247+
text: schema.String({ required: true }),
248+
completed: schema.Boolean({ defaultValue: false }),
249+
},
250+
{ withCid: true },
251+
),
252+
(t) => t.$cid, // uses Loro container id; see "$cid" section below
227253
),
228254
});
229255

@@ -246,19 +272,19 @@ function App() {
246272
// Todo list component
247273
function TodoList() {
248274
const todos = useLoroSelector((state) => state.todos);
249-
const toggleTodo = useLoroAction((s, id: string) => {
250-
const i = s.todos.findIndex((t) => t.id === id);
275+
const toggleTodo = useLoroAction((s, cid: string) => {
276+
const i = s.todos.findIndex((t) => t.$cid === cid);
251277
if (i !== -1) s.todos[i].completed = !s.todos[i].completed;
252278
}, []);
253279

254280
return (
255281
<ul>
256282
{todos.map((todo) => (
257-
<li key={todo.id}>
283+
<li key={todo.$cid}>
258284
<input
259285
type="checkbox"
260286
checked={todo.completed}
261-
onChange={() => toggleTodo(todo.id)}
287+
onChange={() => toggleTodo(todo.$cid)} // `$cid` is the Loro container id
262288
/>
263289
<span>{todo.text}</span>
264290
</li>
@@ -274,7 +300,6 @@ function AddTodoForm() {
274300
const addTodo = useLoroAction(
275301
(state) => {
276302
state.todos.push({
277-
id: Date.now().toString(),
278303
text: text.trim(),
279304
completed: false,
280305
});
@@ -378,12 +403,14 @@ import { Mirror, schema, SyncDirection } from "loro-mirror";
378403

379404
const todoSchema = schema({
380405
todos: schema.LoroList(
381-
schema.LoroMap({
382-
id: schema.String({ required: true }),
383-
text: schema.String({ required: true }),
384-
completed: schema.Boolean({ defaultValue: false }),
385-
}),
386-
(t) => t.id,
406+
schema.LoroMap(
407+
{
408+
text: schema.String({ required: true }),
409+
completed: schema.Boolean({ defaultValue: false }),
410+
},
411+
{ withCid: true },
412+
),
413+
(t) => t.$cid, // stable id from Loro container id ($cid)
387414
),
388415
});
389416

@@ -403,14 +430,19 @@ const unsubscribe = mirror.subscribe((state, { direction, tags }) => {
403430
mirror.setState(
404431
(s) => {
405432
s.todos.push({
406-
id: Date.now().toString(),
407433
text: "Write docs",
408434
completed: false,
409435
});
410436
},
411437
{ tags: ["ui:add"] },
412438
);
413439

440+
### How `$cid` Works
441+
442+
- Every Loro container has a stable container ID provided by Loro (e.g., a maps `container.id`).
443+
- When you enable `withCid: true` on `schema.LoroMap(...)`, Mirror injects a read-only `$cid` field into the mirrored state that equals the underlying Loro container ID.
444+
- `$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)`.
445+
414446
// Cleanup
415447
unsubscribe();
416448
mirror.dispose();

packages/core/README.md

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ const appSchema = schema({
1616
title: schema.String({ defaultValue: "Docs" }),
1717
darkMode: schema.Boolean({ defaultValue: false }),
1818
}),
19-
// LoroList: array of items (ID selector optional but recommended)
19+
// LoroList: array of items (use `$cid` from withCid maps)
2020
todos: schema.LoroList(
21-
schema.LoroMap({
22-
id: schema.String({ required: true }),
23-
text: schema.String(),
24-
}),
25-
(t) => t.id,
21+
schema.LoroMap(
22+
{
23+
text: schema.String(),
24+
},
25+
{ withCid: true },
26+
),
27+
(t) => t.$cid, // `$cid` reuses Loro container id (explained later)
2628
),
2729
// LoroText: collaborative text (string in state)
2830
notes: schema.LoroText(),
@@ -37,13 +39,13 @@ const state = store.getState();
3739
store.setState({
3840
...state,
3941
settings: { ...state.settings, darkMode: true },
40-
todos: [...state.todos, { id: "a", text: "Add milk" }],
42+
todos: [...state.todos, { text: "Add milk" }],
4143
notes: "Hello, team!",
4244
});
4345

4446
// Or mutate a draft (Immer-style)
4547
store.setState((s) => {
46-
s.todos.push({ id: "b", text: "Ship" });
48+
s.todos.push({ text: "Ship" });
4749
s.settings.title = "Project";
4850
});
4951

@@ -118,14 +120,18 @@ Types: `SyncDirection`, `UpdateMetadata`, `SetStateOptions`.
118120

119121
Signatures:
120122

121-
- `schema.LoroMap(definition, options?)`
123+
- `schema.LoroMap(definition, options?)` — supports `{ withCid: true }` to inject a read-only `$cid` field in mirrored state equal to the underlying Loro container id (applies to root/nested maps, list items, and tree node `data` maps).
122124
- `schema.LoroList(itemSchema, idSelector?: (item) => string, options?)`
123125
- `schema.LoroMovableList(itemSchema, idSelector: (item) => string, options?)`
124126
- `schema.LoroText(options?)`
125127
- `schema.LoroTree(nodeMapSchema, options?)`
126128

127129
SchemaOptions for any field: `{ required?: boolean; defaultValue?: unknown; description?: string; validate?: (value) => boolean | string }`.
128130

131+
Reserved key `$cid` (when `withCid: true`):
132+
133+
- `$cid` is injected into mirrored state only; 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)`).
134+
129135
### Validators & Helpers
130136

131137
- `validateSchema(schema, value)` — returns `{ valid: boolean; errors?: string[] }`
@@ -159,8 +165,8 @@ import { LoroDoc } from "loro-crdt";
159165

160166
const todosSchema = schema({
161167
todos: schema.LoroList(
162-
schema.LoroMap({ id: schema.String(), text: schema.String() }),
163-
(t) => t.id,
168+
schema.LoroMap({ text: schema.String() }, { withCid: true }),
169+
(t) => t.$cid, // list selector uses `$cid` (Loro container id)
164170
),
165171
});
166172

@@ -174,15 +180,15 @@ export function App() {
174180
<button
175181
onClick={() =>
176182
setState((s) => {
177-
s.todos.push({ id: crypto.randomUUID(), text: "New" });
183+
s.todos.push({ text: "New" });
178184
})
179185
}
180186
>
181187
Add
182188
</button>
183189
<ul>
184190
{state.todos.map((t) => (
185-
<li key={t.id}>{t.text}</li>
191+
<li key={t.$cid /* stable key from Loro container id */}>{t.text}</li>
186192
))}
187193
</ul>
188194
</div>

packages/core/src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const CID_KEY = "$cid" as const;
2+

packages/core/src/core/diff.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
SchemaType,
2424
} from "../schema";
2525
import { ChangeKinds, InferContainerOptions, type Change } from "./mirror";
26+
import { CID_KEY } from "../constants";
2627

2728
import {
2829
containerIdToContainerType,
@@ -963,6 +964,14 @@ export function diffMap<S extends ObjectLike>(
963964

964965
// Check for removed keys
965966
for (const key in oldStateObj) {
967+
// Skip synthetic CID field for maps with withCid option
968+
if (
969+
key === CID_KEY &&
970+
(schema as LoroMapSchema<Record<string, SchemaType>> | undefined)
971+
?.options?.withCid
972+
) {
973+
continue;
974+
}
966975
// Skip ignored fields defined in schema
967976
const childSchemaForDelete = (
968977
schema as LoroMapSchema<Record<string, SchemaType>> | undefined
@@ -982,6 +991,14 @@ export function diffMap<S extends ObjectLike>(
982991

983992
// Check for added or modified keys
984993
for (const key in newStateObj) {
994+
// Skip synthetic CID field for maps with withCid option
995+
if (
996+
key === CID_KEY &&
997+
(schema as LoroMapSchema<Record<string, SchemaType>> | undefined)
998+
?.options?.withCid
999+
) {
1000+
continue;
1001+
}
9851002
const oldItem = oldStateObj[key];
9861003
const newItem = newStateObj[key];
9871004

0 commit comments

Comments
 (0)