Skip to content

Commit 9817678

Browse files
authored
fix(jotai): only subscribe the state which is from loro (#34)
1 parent 81fa104 commit 9817678

File tree

3 files changed

+114
-91
lines changed

3 files changed

+114
-91
lines changed

packages/jotai/README.md

Lines changed: 52 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Loro Mirror for Jotai
22

3-
Jotai integration for Loro Mirror, providing atomic state management with Loro CRDT synchronization.
3+
Jotai integration for Loro Mirror, providing atomic state management with Loro
4+
CRDT synchronization.
45

56
## Installation
67

@@ -17,7 +18,8 @@ yarn add loro-mirror-jotai jotai loro-crdt
1718

1819
## Usage
1920

20-
Create a `loroMirrorAtom` to represent your shared state. It syncs automatically with the provided Loro document.
21+
Create a `loroMirrorAtom` to represent your shared state. It syncs automatically
22+
with the provided Loro document.
2123

2224
```tsx
2325
import { useAtom } from 'jotai';
@@ -27,49 +29,58 @@ import { loroMirrorAtom } from 'loro-mirror-jotai';
2729

2830
type TodoStatus = "todo" | "inProgress" | "done";
2931

30-
// 1. Define your schema
31-
const todoSchema = schema({
32-
todos: schema.LoroList(
33-
schema.LoroMap({
34-
text: schema.String(),
35-
status: schema.String<TodoStatus>()
36-
}),
37-
(t) => t.$cid, // stable id from Loro container id
38-
),
32+
const todoSchema = schema.LoroMap(
33+
{
34+
text: schema.String(),
35+
status: schema.String<TodoStatus>()
36+
}
37+
)
38+
// Define your schema
39+
const todoDocSchema = schema({
40+
todos: schema.LoroList(
41+
todoSchema,
42+
(t) => t.$cid, // stable id from Loro container id
43+
)
3944
});
4045

41-
// 2. Create a Loro document instance
46+
// Auto generated type from schema
47+
type Todo = InferType<typeof todoSchema>;
48+
49+
// Create a Loro document instance
4250
const doc = new LoroDoc();
4351

44-
// 3. Create the Jotai atom with Loro Mirror config
52+
// Maybe subscribe the doc for persisting
53+
let sub = doc.subscribe(......)
54+
55+
// Create the Jotai atom with Loro Mirror config
4556
// Optionally pass onError to handle async failures
46-
const todoAtom = loroMirrorAtom({
47-
doc,
48-
schema: todoSchema,
49-
initialState: { todos: [] },
50-
// onError: (err) => console.error('update failed', err),
57+
const todoDocAtom = loroMirrorAtom({
58+
doc,
59+
schema: todoDocSchema,
60+
initialState: { todos: [] as Todo[] },
61+
// onError: (err) => console.error('update failed', err),
5162
});
5263

53-
// 4. Use it in your React component
64+
// Selector atom
65+
const todosAtom = atom(get => get(todoDocAtom).todos, (_get, set, todos: Todo[]) => {
66+
set(todoDocAtom, { todos })
67+
})
68+
69+
// Action atom
70+
const addTodoAtom = atom(null, (get, set, todo: Todo) => {
71+
set(todosAtom, [...get(todosAtom), todo])
72+
})
73+
74+
// Use it in your React component
5475
function TodoApp() {
55-
const [state, setState] = useAtom(todoAtom);
56-
57-
const addTodo = async () => {
58-
// Setter returns a Promise; await to catch validation errors or ensure ordering
59-
await setState((prevState) => ({
60-
todos: [
61-
...prevState.todos,
62-
{
63-
text: 'New Todo',
64-
status: "todo",
65-
},
66-
],
67-
}));
68-
};
76+
const todos = useAtomValue(todosAtom);
77+
const addTodo = useSetAtom(addTodoAtom);
6978

7079
return (
7180
<div>
72-
<button onClick={addTodo}>Add Todo</button>
81+
<button onClick={()=>{
82+
addTodo({text: "New todo", status: "todo"})
83+
}}>Add Todo</button>
7384
<ul>
7485
{state.todos.map((todo) => (
7586
<li key={todo.$cid /* stable key from Loro container id */}>
@@ -84,13 +95,17 @@ function TodoApp() {
8495

8596
### Async behavior
8697

87-
- The setter returned by `useAtom(loroMirrorAtom(...))` returns a Promise. Await it when you need deterministic ordering or to handle validation/consistency errors.
98+
- The setter returned by `useAtom(loroMirrorAtom(...))` returns a Promise. Await
99+
it when you need deterministic ordering or to handle validation/consistency
100+
errors.
88101
- You can also pass `onError` in the atom config to catch rejections centrally.
89102

90103
### About `$cid`
91104

92-
- `$cid` is always present on `LoroMap` state and equals the underlying Loro container id.
93-
- Use `$cid` as a stable list selector and React key: `schema.LoroList(item, x => x.$cid)` and `<li key={todo.$cid}>`.
105+
- `$cid` is always present on `LoroMap` state and equals the underlying Loro
106+
container id.
107+
- Use `$cid` as a stable list selector and React key:
108+
`schema.LoroList(item, x => x.$cid)` and `<li key={todo.$cid}>`.
94109

95110
## License
96111

packages/jotai/src/index.ts

Lines changed: 14 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
* Each piece of state is represented as an atom, enabling fine-grained reactivity and composition.
66
*/
77

8-
import { atom, WritableAtom } from "jotai";
8+
import { atom } from 'jotai';
99

1010
// Import types only to avoid module resolution issues
1111
import type { LoroDoc } from "loro-crdt";
12-
import { SchemaType, InferType, InferInputType, Mirror } from "loro-mirror";
12+
import { SyncDirection, Mirror } from "loro-mirror";
13+
import type { SchemaType, InferType, InferInputType } from "loro-mirror";
1314

1415
/**
1516
* Configuration for creating a Loro Mirror atom
@@ -85,51 +86,35 @@ export interface LoroMirrorAtomConfig<S extends SchemaType> {
8586
*/
8687
export function loroMirrorAtom<S extends SchemaType>(
8788
config: LoroMirrorAtomConfig<S>,
88-
): WritableAtom<
89-
InferType<S>,
90-
[InferInputType<S> | ((prev: InferType<S>) => InferInputType<S>)],
91-
Promise<void>
92-
> {
89+
) {
9390
const store = new Mirror(config);
94-
const stateAtom = atom<InferType<S>>(store.getState() as InferType<S>);
95-
const subAtom = atom<null, [InferType<S>], void>(
91+
const stateAtom = atom(store.getState() as InferType<S>);
92+
const subAtom = atom(
9693
null,
97-
(_get, set, update) => {
94+
(_get, set, update: InferType<S>) => {
9895
set(stateAtom, update);
9996
},
10097
);
10198

10299
subAtom.onMount = (set) => {
103-
const sub = store.subscribe((state) => {
104-
set(state);
100+
const sub = store.subscribe((state, { direction }) => {
101+
if (direction === SyncDirection.FROM_LORO) {
102+
set(state);
103+
}
105104
});
106105
return () => {
107106
sub?.();
108107
};
109108
};
110109

111-
const base = atom<
112-
InferType<S>,
113-
[InferInputType<S> | ((prev: InferType<S>) => InferInputType<S>)],
114-
Promise<void>
115-
>(
110+
const base = atom(
116111
(get) => {
117112
get(subAtom);
118113
return get(stateAtom);
119114
},
120-
async (get, set, update) => {
121-
const currentState = get(stateAtom) as InferType<S>;
115+
async (_get, set, update: Partial<InferInputType<S>>) => {
122116
try {
123-
if (typeof update === "function") {
124-
const nextInput = (
125-
update as (prev: InferType<S>) => InferInputType<S>
126-
)(currentState);
127-
await store.setState(
128-
nextInput as Partial<InferInputType<S>>,
129-
);
130-
} else {
131-
await store.setState(update as Partial<InferInputType<S>>);
132-
}
117+
await store.setState(update);
133118
// Reflect latest state from Mirror after any stamping like $cid
134119
set(stateAtom, store.getState() as InferType<S>);
135120
} catch (err) {
Lines changed: 48 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,71 @@
1-
import { describe, it, expect } from "vitest";
2-
import { renderHook, act } from "@testing-library/react";
1+
import { describe, expect, it } from "vitest";
2+
import { act, renderHook } from "@testing-library/react";
33
import { LoroDoc } from "loro-crdt";
4-
import { schema } from "loro-mirror";
4+
import { InferInputType, schema } from "loro-mirror";
55
import { loroMirrorAtom } from "../src";
6-
import { useAtom } from "jotai";
6+
import { atom, useAtomValue, useSetAtom } from "jotai";
77

88
describe("Jotai README example", () => {
99
it("creates an atom with list items that include $cid and updates state", async () => {
1010
type TodoStatus = "todo" | "inProgress" | "done";
1111

12-
const todoSchema = schema({
12+
const todoSchema = schema.LoroMap(
13+
{
14+
text: schema.String(),
15+
status: schema.String<TodoStatus>(),
16+
},
17+
{ withCid: true },
18+
);
19+
// Define your schema
20+
const todoDocSchema = schema({
1321
todos: schema.LoroList(
14-
schema.LoroMap({
15-
text: schema.String(),
16-
status: schema.String<TodoStatus>(),
17-
}),
18-
(t) => t.$cid,
22+
todoSchema,
23+
(t) => t.$cid, // stable id from Loro container id
1924
),
2025
});
2126

27+
// Auto generated type from schema
28+
type Todo = InferInputType<typeof todoSchema>;
29+
2230
const doc = new LoroDoc();
23-
const atom = loroMirrorAtom({
31+
const todoDocAtom = loroMirrorAtom({
2432
doc,
25-
schema: todoSchema,
26-
initialState: { todos: [] },
33+
schema: todoDocSchema,
34+
initialState: { todos: [] as Todo[] },
35+
// onError: (err) => console.error('update failed', err),
2736
});
2837

29-
const { result } = renderHook(() => useAtom(atom));
38+
// Selector atom
39+
const todosAtom = atom(
40+
(get) => get(todoDocAtom).todos,
41+
(_get, set, todos: Todo[]) => {
42+
set(todoDocAtom, { todos });
43+
},
44+
);
45+
46+
// Action atom
47+
const addTodoAtom = atom(null, (get, set, todo: Todo) => {
48+
set(todosAtom, [...get(todosAtom), todo]);
49+
});
3050

51+
const { result: todosResult } = renderHook(() =>
52+
useAtomValue(todosAtom)
53+
);
54+
const { result: addTodoResult } = renderHook(() =>
55+
useSetAtom(addTodoAtom)
56+
);
3157
// push an item
3258
await act(async () => {
33-
await result.current[1]((prev) => ({
34-
todos: [
35-
...prev.todos,
36-
{ text: "New Todo", status: "todo" as TodoStatus },
37-
],
38-
}));
59+
addTodoResult.current(
60+
{ text: "New Todo", status: "todo" as TodoStatus },
61+
);
3962
});
4063

41-
const state = result.current[0];
42-
expect(state.todos.length).toBe(1);
43-
expect(state.todos[0].text).toBe("New Todo");
44-
expect(state.todos[0].status).toBe("todo");
64+
const state = todosResult.current;
65+
expect(state.length).toBe(1);
66+
expect(state[0].text).toBe("New Todo");
67+
expect(state[0].status).toBe("todo");
4568
// $cid should be injected
46-
expect(typeof state.todos[0].$cid).toBe("string");
69+
expect(typeof state[0].$cid).toBe("string");
4770
});
4871
});

0 commit comments

Comments
 (0)