Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,14 @@
- Test all: `pnpm test` (Vitest across packages).
- Lint all: `pnpm lint` (ESLint on `src` in each package).
- Type check: `pnpm typecheck` (TS `--noEmit`).
- Per-package examples:
- Core build: `pnpm --filter loro-mirror build`
- React tests (watch): `pnpm --filter loro-mirror-react test:watch`

## Coding Style & Naming Conventions

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

## Testing Guidelines

Expand Down
110 changes: 71 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,15 @@ import { schema, createStore } from "loro-mirror";
// Define your schema
const todoSchema = schema({
todos: schema.LoroList(
schema.LoroMap({
id: schema.String(),
text: schema.String(),
completed: schema.Boolean({ defaultValue: false }),
}),
schema.LoroMap(
{
text: schema.String(),
completed: schema.Boolean({ defaultValue: false }),
},
{ withCid: true },
),
// Use `$cid` (reuses Loro container id; explained later)
(t) => t.$cid,
),
});

Expand All @@ -63,7 +67,6 @@ store.setState((s) => ({
todos: [
...s.todos,
{
id: Date.now().toString(),
text: "Learn Loro Mirror",
completed: false,
},
Expand All @@ -73,11 +76,11 @@ store.setState((s) => ({
// Or: draft-style updates (mutate a draft)
store.setState((state) => {
state.todos.push({
id: Date.now().toString(),
text: "Learn Loro Mirror",
completed: false,
});
// no return needed
// `$cid` is injected automatically for withCid maps
// and reuses the underlying Loro container id (explained later)
});

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

type UserId = string & { __brand: "userId" };
const appSchema = schema({
user: schema.LoroMap({
id: schema.String<UserId>(),
name: schema.String(),
age: schema.Number({ required: false }),
}),
user: schema.LoroMap(
{
name: schema.String(),
age: schema.Number({ required: false }),
},
{ withCid: true },
),
tags: schema.LoroList(schema.String()),
});

// Inferred state type:
// type AppState = {
// user: { id: UserId; name: string; age: number | undefined };
// user: { $cid: string; name: string; age: number | undefined };
// tags: string[];
// }
type AppState = InferType<typeof appSchema>;
```

> **Note**: If you need optional custom string types like `{ id?: UserId }`, you currently need to explicitly define it as `schema.String<UserId>({ required: false })`
> **Note**: If you need optional custom string types with generics (e.g., `{ status?: Status }`), explicitly define them as `schema.String<Status>({ required: false })`.

For `LoroMap` with dynamic key-value pairs:

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

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

With `withCid: true` on a `LoroMap` or `LoroMapRecord`, the inferred type gains a `$cid: string` field. For example:

```ts
const user = schema.LoroMap({ name: schema.String() }, { withCid: true });
// InferType<typeof user> => { name: string; $cid: string }

// In lists, `$cid` is handy as a stable idSelector:
const users = schema.LoroList(user, (x) => x.$cid);
```

#### Default Values & Creation

- Explicitly specified `defaultValue` takes the highest precedence.
Expand Down Expand Up @@ -194,12 +211,14 @@ const result = validateSchema(appSchema, {
```ts
const todoSchema = schema({
todos: schema.LoroMovableList(
schema.LoroMap({
id: schema.String(),
text: schema.String(),
completed: schema.Boolean({ defaultValue: false }),
}),
(t) => t.id,
schema.LoroMap(
{
text: schema.String(),
completed: schema.Boolean({ defaultValue: false }),
},
{ withCid: true },
),
(t) => t.$cid, // stable id from Loro container id ($cid)
),
});
```
Expand All @@ -208,6 +227,10 @@ const todoSchema = schema({

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

#### Reserved Field: `$cid`

- `$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`).

### React Usage

```tsx
Expand All @@ -216,14 +239,17 @@ import { LoroDoc } from "loro-crdt";
import { schema } from "loro-mirror";
import { createLoroContext } from "loro-mirror-react";

// Define your schema
// Define your schema (use `$cid` from withCid maps)
const todoSchema = schema({
todos: schema.LoroList(
schema.LoroMap({
id: schema.String({ required: true }),
text: schema.String({ required: true }),
completed: schema.Boolean({ defaultValue: false }),
}),
schema.LoroMap(
{
text: schema.String({ required: true }),
completed: schema.Boolean({ defaultValue: false }),
},
{ withCid: true },
),
(t) => t.$cid, // uses Loro container id; see "$cid" section below
),
});

Expand All @@ -246,19 +272,19 @@ function App() {
// Todo list component
function TodoList() {
const todos = useLoroSelector((state) => state.todos);
const toggleTodo = useLoroAction((s, id: string) => {
const i = s.todos.findIndex((t) => t.id === id);
const toggleTodo = useLoroAction((s, cid: string) => {
const i = s.todos.findIndex((t) => t.$cid === cid);
if (i !== -1) s.todos[i].completed = !s.todos[i].completed;
}, []);

return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<li key={todo.$cid}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
onChange={() => toggleTodo(todo.$cid)} // `$cid` is the Loro container id
/>
<span>{todo.text}</span>
</li>
Expand All @@ -274,7 +300,6 @@ function AddTodoForm() {
const addTodo = useLoroAction(
(state) => {
state.todos.push({
id: Date.now().toString(),
text: text.trim(),
completed: false,
});
Expand Down Expand Up @@ -378,12 +403,14 @@ import { Mirror, schema, SyncDirection } from "loro-mirror";

const todoSchema = schema({
todos: schema.LoroList(
schema.LoroMap({
id: schema.String({ required: true }),
text: schema.String({ required: true }),
completed: schema.Boolean({ defaultValue: false }),
}),
(t) => t.id,
schema.LoroMap(
{
text: schema.String({ required: true }),
completed: schema.Boolean({ defaultValue: false }),
},
{ withCid: true },
),
(t) => t.$cid, // stable id from Loro container id ($cid)
),
});

Expand All @@ -403,14 +430,19 @@ const unsubscribe = mirror.subscribe((state, { direction, tags }) => {
mirror.setState(
(s) => {
s.todos.push({
id: Date.now().toString(),
text: "Write docs",
completed: false,
});
},
{ tags: ["ui:add"] },
);

### How `$cid` Works

- Every Loro container has a stable container ID provided by Loro (e.g., a map’s `container.id`).
- 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.
- `$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)`.

// Cleanup
unsubscribe();
mirror.dispose();
Expand Down
32 changes: 19 additions & 13 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ const appSchema = schema({
title: schema.String({ defaultValue: "Docs" }),
darkMode: schema.Boolean({ defaultValue: false }),
}),
// LoroList: array of items (ID selector optional but recommended)
// LoroList: array of items (use `$cid` from withCid maps)
todos: schema.LoroList(
schema.LoroMap({
id: schema.String({ required: true }),
text: schema.String(),
}),
(t) => t.id,
schema.LoroMap(
{
text: schema.String(),
},
{ withCid: true },
),
(t) => t.$cid, // `$cid` reuses Loro container id (explained later)
),
// LoroText: collaborative text (string in state)
notes: schema.LoroText(),
Expand All @@ -37,13 +39,13 @@ const state = store.getState();
store.setState({
...state,
settings: { ...state.settings, darkMode: true },
todos: [...state.todos, { id: "a", text: "Add milk" }],
todos: [...state.todos, { text: "Add milk" }],
notes: "Hello, team!",
});

// Or mutate a draft (Immer-style)
store.setState((s) => {
s.todos.push({ id: "b", text: "Ship" });
s.todos.push({ text: "Ship" });
s.settings.title = "Project";
});

Expand Down Expand Up @@ -118,14 +120,18 @@ Types: `SyncDirection`, `UpdateMetadata`, `SetStateOptions`.

Signatures:

- `schema.LoroMap(definition, options?)`
- `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).
- `schema.LoroList(itemSchema, idSelector?: (item) => string, options?)`
- `schema.LoroMovableList(itemSchema, idSelector: (item) => string, options?)`
- `schema.LoroText(options?)`
- `schema.LoroTree(nodeMapSchema, options?)`

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

Reserved key `$cid` (when `withCid: true`):

- `$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)`).

### Validators & Helpers

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

const todosSchema = schema({
todos: schema.LoroList(
schema.LoroMap({ id: schema.String(), text: schema.String() }),
(t) => t.id,
schema.LoroMap({ text: schema.String() }, { withCid: true }),
(t) => t.$cid, // list selector uses `$cid` (Loro container id)
),
});

Expand All @@ -174,15 +180,15 @@ export function App() {
<button
onClick={() =>
setState((s) => {
s.todos.push({ id: crypto.randomUUID(), text: "New" });
s.todos.push({ text: "New" });
})
}
>
Add
</button>
<ul>
{state.todos.map((t) => (
<li key={t.id}>{t.text}</li>
<li key={t.$cid /* stable key from Loro container id */}>{t.text}</li>
))}
</ul>
</div>
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const CID_KEY = "$cid" as const;

17 changes: 17 additions & 0 deletions packages/core/src/core/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
SchemaType,
} from "../schema";
import { ChangeKinds, InferContainerOptions, type Change } from "./mirror";
import { CID_KEY } from "../constants";

import {
containerIdToContainerType,
Expand Down Expand Up @@ -963,6 +964,14 @@ export function diffMap<S extends ObjectLike>(

// Check for removed keys
for (const key in oldStateObj) {
// Skip synthetic CID field for maps with withCid option
if (
key === CID_KEY &&
(schema as LoroMapSchema<Record<string, SchemaType>> | undefined)
?.options?.withCid
) {
continue;
}
// Skip ignored fields defined in schema
const childSchemaForDelete = (
schema as LoroMapSchema<Record<string, SchemaType>> | undefined
Expand All @@ -982,6 +991,14 @@ export function diffMap<S extends ObjectLike>(

// Check for added or modified keys
for (const key in newStateObj) {
// Skip synthetic CID field for maps with withCid option
if (
key === CID_KEY &&
(schema as LoroMapSchema<Record<string, SchemaType>> | undefined)
?.options?.withCid
) {
continue;
}
const oldItem = oldStateObj[key];
const newItem = newStateObj[key];

Expand Down
Loading