Skip to content

Commit 3990702

Browse files
authored
Feat: LoroTree (#16)
* feat: tree support init * docs: tree * fix: tree apply * refactor: refine a few details * fix: a few more errors * fix: get parent key node correctly for tree * fix(core): assign TreeIDs via onCreate and patch child parent in batch creates - Add onCreate callback to tree-create changes (diff.ts) to: - write the assigned TreeID back into the new state node - retarget queued child creates’ parent to the freshly created parent ID - Apply tree creates in order and invoke onCreate immediately after createNode (mirror.ts) to ensure children receive the correct parent ID in the same update - Document Tree create ordering & IDs in README with example (incl. Chinese note) - Improve type checks: isValueOfContainerType recognizes array shapes for List/Tree (utils.ts) - Add comments and optional debug divergence check for Tree updates (mirror.ts) - Update tests for tree behavior and quoting consistency Rationale: users cannot know new TreeIDs ahead of time; when creating a parent and its children together, the parent’s ID is unknown at diff time. The onCreate hook backfills IDs and patches child parent references to keep state and Loro in sync without rebuilding entire subtrees. * test: add loro tree fuzzing tests
1 parent 90944c8 commit 3990702

File tree

15 files changed

+2386
-137
lines changed

15 files changed

+2386
-137
lines changed

.eslintrc.js

Lines changed: 0 additions & 26 deletions
This file was deleted.

.oxlintrc.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"suspicious": "warn"
55
},
66
"rules": {
7-
"typescript/no-explicit-any": "error",
7+
"typescript/no-explicit-any": "warn",
88
"typescript/no-unsafe-type-assertion": "off"
99
},
1010
"overrides": [

IMPLEMENTATION_PLAN.md

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# Implementation Plan: LoroTree Support in Mirror
2+
3+
Status: Completed • Owner: Core • Last updated: 2025-08-28
4+
5+
## Goals
6+
7+
- Provide first-class LoroTree support in Mirror for bidirectional sync between app state and loro-crdt Tree containers.
8+
- Maintain parity with existing Map/List/Text/MovableList flows, including schema validation and event-driven state updates.
9+
10+
## Non-Goals
11+
12+
- Rich tree-aware UIs (out of scope, Mirror focuses on sync/state).
13+
- Automatic inference of Tree from plain arrays without schema.
14+
15+
## Current Gaps (as-is)
16+
17+
- Mirror has no Tree handling in: root init, nested registration, read path, or write path.
18+
- loroEventApply does not apply `tree` diffs; path walker cannot resolve nodes by id within arrays.
19+
- diff does not compute structural Tree diffs.
20+
- Schema lacks `loro-tree` type/guards/defaults; utils lacks `Tree` in helpers.
21+
22+
## State Model
23+
24+
Represent a Tree in Mirror state as nested nodes:
25+
26+
```ts
27+
type TreeNode<T = Record<string, unknown>> = {
28+
id: string; // TreeID string from Loro
29+
data: T; // node metadata (validated by nodeSchema)
30+
children: TreeNode[]; // ordered children
31+
}
32+
33+
// Mirror state value for a LoroTree: array of roots
34+
type TreeValue<T> = TreeNode<T>[]
35+
```
36+
37+
Notes:
38+
- When app creates new nodes via state, `id` may be omitted; Loro assigns it on create; events will fill it back.
39+
- Node data is a LoroMap schema (`nodeSchema`) for validation and nested containers.
40+
41+
## Public Schema API Changes
42+
43+
- Add `schema.LoroTree(nodeSchema, options?)`
44+
- `type: "loro-tree"`, `getContainerType(): "Tree"`.
45+
- `nodeSchema`: `LoroMapSchema<Record<string, SchemaType>>` for `node.data`.
46+
- `types.ts`
47+
- Add `LoroTreeSchema<T>` to `SchemaType` and `ContainerSchemaType` unions.
48+
- `InferType<LoroTreeSchema<T>>` resolves to `Array<{ id: string; data: InferType<T>; children: ... }>`.
49+
- `validators.ts`
50+
- Add `isLoroTreeSchema` type guard.
51+
- `validateSchema` support: top-level is `Array`; validate `node.data` recursively using `nodeSchema`; validate `children` recursively.
52+
- `getDefaultValue` returns `[]` when required; else `undefined`.
53+
54+
## Core Changes (Read Path)
55+
56+
`loroEventApply.ts`
57+
- Implement `tree` diff application:
58+
- Initialize target as `[]` if missing.
59+
- `create`: insert `{ id, data: {}, children: [] }` at `parent/index` (root if `parent` undefined).
60+
- `move`: remove from `oldParent/oldIndex` and insert at `parent/index` (if same parent and `oldIndex < index`, decrement target index).
61+
- `delete`: remove subtree at `oldParent/oldIndex`.
62+
- Enhance path resolution to support node lookup by `id` inside arrays so map diffs to `node.data` apply cleanly:
63+
- When current is an array and next segment is a string, interpret as `TreeID` string and select element with `elem.id === seg`.
64+
- Continue using `applyMapDiff` for `node.data` changes.
65+
66+
## Core Changes (Write Path)
67+
68+
`diff.ts`
69+
- Extend `diffContainer` to handle `ContainerType === "Tree"` with `diffTree(...)`.
70+
- Implement `diffTree` (structure + node data):
71+
- Build old/new id->node maps and parent relationships.
72+
- Deletions: nodes in old not in new (delete deepest-first to avoid orphaning).
73+
- Creates: nodes in new not in old (create top-down so parents exist).
74+
- Moves: common nodes whose `(parentId, index)` changed.
75+
- Node data updates: for common nodes, route to `diffContainer` of `node.data` via the attached `LoroMap` container id.
76+
77+
`mirror.ts`
78+
- `Change` union: add Tree operations
79+
- `tree-create`: `{ container: ContainerID, kind: "tree-create", parent?: TreeID, index: number }`
80+
- `tree-move`: `{ container: ContainerID, kind: "tree-move", target: TreeID, parent?: TreeID, index: number }`
81+
- `tree-delete`: `{ container: ContainerID, kind: "tree-delete", target: TreeID }`
82+
- Initialization
83+
- Include `"loro-tree"` in root container registration and `getRootContainerByType` calls.
84+
- For Tree nested registration, when visiting nodes, register `node.data` container with `nodeSchema`.
85+
- Loro event registration
86+
- For `event.diff.type === "tree"`, on `create` resolve node then `registerContainer(node.data.id, nodeSchema)`.
87+
- Apply changes
88+
- `applyRootChanges`: support `"loro-tree"` root and forward to `updateTopLevelContainer`.
89+
- `applyContainerChanges`: add `case "Tree"` to handle `tree-*` changes via `LoroTree.createNode/move/delete`.
90+
- `updateTopLevelContainer`: add `"Tree"` branch to compute tree diffs and apply.
91+
- `initializeContainer`: when kind `"Tree"` and initial value present, seed structure with `createNode`, then initialize each `node.data` using schema.
92+
- `createContainerFromSchema`: return `[new LoroTree(), "Tree"]` for `"loro-tree"`.
93+
- `getSchemaForChild`: when parent schema is `loro-tree`, return `nodeSchema` for node data.
94+
95+
`utils.ts`
96+
- `getRootContainerByType`: add `"Tree" -> doc.getTree(key)`.
97+
- Do not infer `Tree` in `tryInferContainerType` (requires schema to avoid ambiguity with plain lists).
98+
99+
## Tests
100+
101+
New: `packages/core/tests/core/mirror-tree.test.ts`
102+
- FROM_LORO: create/move/delete in Loro updates Mirror state (`{id,data,children}`), including nested `data` updates.
103+
- TO_LORO: mutating state (new nodes without id, moves, deletes, data changes) updates Loro via `tree-*` changes and map updates.
104+
- Mixed operations in one `setState` produce consistent changes.
105+
- Fractional index compatible: numeric `index` passed; Loro handles ordering.
106+
107+
## Edge Cases & Performance
108+
109+
- Same-parent move index adjustment when `oldIndex < index`.
110+
- Deepest-first deletion ordering.
111+
- Optional in-memory index per tree during event application for O(1) node lookup (can be a follow-up optimization).
112+
- Concurrency handled by Loro; Mirror applies diffs idempotently.
113+
114+
## Rollout & Verification
115+
116+
1) Baseline
117+
- [x] `pnpm build && pnpm test && pnpm typecheck`
118+
- [ ] `pnpm lint`
119+
120+
2) Schema & Utils
121+
- [x] Add `LoroTreeSchema` to `types.ts` (+ InferType)
122+
- [x] Add `schema.LoroTree()` to `schema/index.ts`
123+
- [x] Add `isLoroTreeSchema`, extend validators and defaults
124+
- [x] Add `Tree` to `getRootContainerByType`
125+
126+
3) Read Path
127+
- [x] loroEventApply: apply `tree` diffs (create/move/delete)
128+
- [x] loroEventApply: path walker supports node id in arrays
129+
- [x] Register `node.data` containers on tree creates
130+
131+
4) Write Path
132+
- [x] diff: add `Tree` branch and implement `diffTree`
133+
- [x] mirror: extend `Change` union with `tree-*`
134+
- [x] mirror: handle `case "Tree"` in `applyContainerChanges`
135+
- [x] mirror: update top-level container branch for `"Tree"`
136+
- [x] mirror: nested registration + initialization for `node.data`
137+
138+
5) Tests
139+
- [x] Add `mirror-tree.test.ts` with FROM_LORO, TO_LORO, mixed flows
140+
- [x] Run and fix regressions
141+
142+
6) Docs
143+
- [ ] README entry: Tree shape `{ id, data, children }` and schema requirements
144+
145+
7) Final QA
146+
- [x] `pnpm build`
147+
- [x] `pnpm test`
148+
- [ ] `pnpm lint`
149+
- [x] `pnpm typecheck`
150+
151+
## Progress Notes
152+
153+
- Normalized tree JSON from `{ id, meta, children }` to `{ id, data, children }` during initialization for consistent state shape.
154+
- Scoped path remapping so only tree node `meta` is treated as `data` (does not affect root `meta` maps), fixing a regression in state.test profile.bio.
155+
- Initial Tree top-level updates rebuild structure; `diffTree` is implemented and can be used at root for minimal ops in a follow-up if desired.
156+
157+
## Risks / Open Questions
158+
159+
- Node identification in state: require `id` to match Loro `TreeID` (string). For newly created nodes without id, Mirror will create and fill id from events; interim state may temporarily show empty `id` values—acceptable for UI with optimistic updates.
160+
- Large tree performance: consider indexing maps for event application if profiling shows hotspots.
161+
- Schema composition: `nodeSchema` can itself contain containers; ensure nested registration paths are correct.

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,29 @@ For detailed documentation, see the README files in each package:
309309
- [React Documentation](./packages/react/README.md)
310310
- [Jotai Documentation](./packages/jotai/README.md)
311311

312+
## Schema Overview
313+
314+
Loro Mirror uses a typed schema to map your app state to Loro containers. Common schema constructors:
315+
316+
- `schema.String(options?)`: string
317+
- `schema.Number(options?)`: number
318+
- `schema.Boolean(options?)`: boolean
319+
- `schema.Ignore(options?)`: exclude from sync (app-only)
320+
- `schema.LoroText(options?)`: rich text (`LoroText`)
321+
- `schema.LoroMap(definition, options?)`: object (`LoroMap`)
322+
- `schema.LoroList(itemSchema, idSelector?, options?)`: list (`LoroList`)
323+
- `schema.LoroMovableList(itemSchema, idSelector, options?)`: movable list that emits move ops (requires `idSelector`)
324+
- `schema.LoroTree(nodeSchema, options?)`: hierarchical tree (`LoroTree`) with per-node `data` map
325+
326+
Tree nodes have the shape `{ id?: string; data: T; children: Node<T>[] }`. Define a tree by passing a node `LoroMap` schema:
327+
328+
```ts
329+
import { schema } from "loro-mirror";
330+
331+
const node = schema.LoroMap({ title: schema.String({ required: true }) });
332+
const mySchema = schema({ outline: schema.LoroTree(node) });
333+
```
334+
312335
## API Reference (Core Mirror)
313336

314337
### Mirror

packages/core/README.md

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,119 @@ const mySchema = schema({
409409
- `schema.LoroText(options?)` - Loro rich text
410410
- `schema.LoroMap(definition, options?)` - Loro map (object)
411411
- `schema.LoroList(itemSchema, idSelector?, options?)` - Loro list (array)
412+
- `schema.LoroMovableList(itemSchema, idSelector, options?)` - Loro movable list that emits stable move operations when order changes (requires an `idSelector`).
413+
- `schema.LoroTree(nodeSchema, options?)` - Loro tree where each node carries a `data` map described by `nodeSchema`.
414+
415+
### Tree Schema
416+
417+
Tree fields let you model hierarchical data with per-node metadata stored in a Loro map.
418+
419+
Shape in app state:
420+
421+
```ts
422+
type TreeNode<T> = {
423+
id?: string; // optional when creating; assigned by Loro if omitted
424+
data: T; // validated by the provided nodeSchema
425+
children: Array<TreeNode<T>>;
426+
};
427+
```
428+
429+
Define a tree by providing a node `LoroMap` schema. Each node's `data` will follow that schema; `children` is always an array of nodes.
430+
431+
```ts
432+
import { schema } from "loro-mirror";
433+
434+
const folderNode = schema.LoroMap({
435+
name: schema.String({ required: true }),
436+
color: schema.String({ defaultValue: "blue" }),
437+
});
438+
439+
const fsSchema = schema({
440+
tree: schema.LoroTree(folderNode),
441+
});
442+
443+
// Example initial state
444+
const initial = {
445+
tree: [
446+
{
447+
data: { name: "root" },
448+
children: [
449+
{ data: { name: "docs" }, children: [] },
450+
{ data: { name: "src" }, children: [] },
451+
],
452+
},
453+
],
454+
};
455+
```
456+
457+
Notes:
458+
459+
- `id` is optional when creating nodes. Mirror will create the node in the Loro tree and use its generated ID. If you supply an existing ID, Mirror will target that node.
460+
- Reordering or moving nodes is done by changing the `children` arrays. Mirror diffs trees and applies minimal `create`/`move`/`delete` operations, and syncs nested `data` map changes.
461+
- Under the hood, Loro serializes node payloads under `meta`. Mirror normalizes it to `data` in app state and schema validation.
462+
463+
#### Updating Trees with `setState`
464+
465+
Use normal `setState` updates; mutate the draft tree and Mirror will apply the minimal tree operations.
466+
467+
Add nodes:
468+
469+
```ts
470+
// Add a root-level node
471+
store.setState((s) => {
472+
s.tree.push({ data: { name: "assets" }, children: [] });
473+
});
474+
475+
// Add a child under an existing parent (by index here)
476+
store.setState((s) => {
477+
const parent = s.tree[0];
478+
parent.children.push({ data: { name: "images" }, children: [] });
479+
});
480+
```
481+
482+
Update node data:
483+
484+
```ts
485+
store.setState((s) => {
486+
const node = s.tree[0].children[0];
487+
node.data.name = "imgs"; // edits sync via the node's LoroMap
488+
});
489+
```
490+
491+
Move or reorder nodes (same parent):
492+
493+
```ts
494+
store.setState((s) => {
495+
// Move last root node to the front
496+
const [n] = s.tree.splice(s.tree.length - 1, 1);
497+
s.tree.splice(0, 0, n);
498+
});
499+
```
500+
501+
Move nodes across parents:
502+
503+
```ts
504+
store.setState((s) => {
505+
const from = s.tree[0].children;
506+
const to = s.tree[1].children;
507+
const [n] = from.splice(0, 1);
508+
to.splice(0, 0, n);
509+
});
510+
```
511+
512+
Delete nodes:
513+
514+
```ts
515+
store.setState((s) => {
516+
// Remove first child of first root node
517+
s.tree[0].children.splice(0, 1);
518+
});
519+
```
520+
521+
Tips:
522+
523+
- Node `id`s are populated by Loro when created. If you don’t specify an `id`, it becomes available on the next state after sync; avoid relying on the new `id` inside the same `setState` call.
524+
- For finding nodes by `id`, traverse `s.tree` recursively and mutate the located node/children array.
412525

413526
### `createStore`
414527

0 commit comments

Comments
 (0)