Skip to content

Commit 27aa95b

Browse files
authored
feat!: make events synchronous by using [email protected] (#39)
* feat: use [email protected] to support synchronous api * docs: simplify by making things sync
1 parent 9817678 commit 27aa95b

File tree

17 files changed

+89
-1129
lines changed

17 files changed

+89
-1129
lines changed

README.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,7 @@ const mirror = new Mirror({
5959
});
6060

6161
// Update the state (immutable update)
62-
// Note: setState is async; await it in non-React code
63-
await mirror.setState((s) => ({
62+
mirror.setState((s) => ({
6463
...s,
6564
todos: [
6665
...s.todos,
@@ -72,7 +71,7 @@ await mirror.setState((s) => ({
7271
}));
7372

7473
// Or: draft-style updates (mutate a draft)
75-
await mirror.setState((state) => {
74+
mirror.setState((state) => {
7675
state.todos.push({
7776
text: "Learn Loro Mirror",
7877
completed: false,
@@ -362,7 +361,7 @@ const mySchema = schema({ outline: schema.LoroTree(node) });
362361
- **`inferOptions`**: `{ defaultLoroText?: boolean; defaultMovableList?: boolean }` – influence container-type inference when inserting containers from plain values.
363362

364363
- `getState(): State`: Returns the current in-memory state view.
365-
- `setState(updater, options?)`: Update state and sync to Loro. Returns a Promise.
364+
- `setState(updater, options?)`: Update state and sync to Loro. Runs synchronously.
366365
- **`updater`**: either a partial object to shallow-merge or a function that may mutate a draft (Immer-style) or return a new state object.
367366
- **`options`**: `{ tags?: string | string[] }` – arbitrary tags attached to this update; delivered to subscribers in metadata.
368367
- `subscribe(callback): () => void`: Subscribe to state changes. `callback` receives `(state, metadata)` where `metadata` includes:
@@ -412,8 +411,8 @@ const unsubscribe = mirror.subscribe((state, { direction, tags }) => {
412411
}
413412
});
414413

415-
// Update with draft mutation + tags (await for deterministic ordering)
416-
await mirror.setState(
414+
// Update with draft mutation + tags
415+
mirror.setState(
417416
(s) => {
418417
s.todos.push({
419418
text: "Write docs",

api.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@
3636

3737
- Methods
3838
- `getState(): InferType<S>` — returns the current mirror state (immutable snapshot)
39-
- `setState(updater, options?): Promise<void>`
40-
- Promise-returning; await in non-React code for ordering/errors.
39+
- `setState(updater, options?): void`
40+
- Synchronous; the state, validation, and subscriber notifications all finish before `setState` returns.
4141
- `updater` supports both styles:
4242
- Mutate a draft: `(draft: InferType<S>) => void`
4343
- Return a new object: `(prev: Readonly<InferInputType<S>>) => InferInputType<S>`
@@ -75,7 +75,7 @@
7575

7676
const mirror = new Mirror({ doc: new LoroDoc(), schema: appSchema });
7777

78-
await mirror.setState((s) => {
78+
mirror.setState((s) => {
7979
s.settings.title = "Docs";
8080
s.todos.push({ id: "1", text: "Ship" });
8181
});
@@ -219,7 +219,7 @@
219219

220220
- Lists: always provide an `idSelector` if items have stable IDs — enables minimal add/update/move/delete instead of positional churn. Prefer `LoroMovableList` when reorder operations are common.
221221
- `$cid` for IDs: Every `LoroMap` includes a stable `$cid` you can use as a React `key` or as a `LoroList` item selector: `(item) => item.$cid`.
222-
- `setState` styles: choose your favorite — draft mutation or returning a new object. Both are supported and return a Promise. Await in non-React code when you need ordering or to catch validation errors.
222+
- `setState` styles: choose your favorite — draft mutation or returning a new object. Both run synchronously, so follow-up logic can safely read the updated state immediately.
223223
- Tagging updates: pass `{ tags: ["analytics", "user"] }` to `setState` and inspect `metadata.tags` in subscribers.
224224
- Trees: you can create/move/delete nodes in state (Mirror emits precise `tree-create/move/delete`). Node `data` is a normal Loro map — nested containers (text, list, map) update incrementally.
225225
- Initial state: providing `initialState` hints shapes and defaults in memory, but does not write into the LoroDoc until a real change occurs.

package.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,6 @@
2222
"license": "MIT",
2323
"devDependencies": {
2424
"@types/node": "^18.0.0",
25-
"@typescript-eslint/eslint-plugin": "^5.0.0",
26-
"@typescript-eslint/parser": "^5.0.0",
27-
"eslint": "^8.0.0",
2825
"oxlint": "^1.12.0",
2926
"oxlint-tsgolint": "^0.0.4",
3027
"typescript": "^4.7.0",

packages/core/README.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,16 @@ const store = new Mirror({ doc, schema: appSchema });
3232
// Read state
3333
const state = store.getState();
3434

35-
// Update (return a new state)
36-
// setState is async; await it in non-React code
37-
await store.setState({
35+
// Update (return a new state) — synchronous; the next line sees the new state.
36+
store.setState({
3837
...state,
3938
settings: { ...state.settings, darkMode: true },
4039
todos: [...state.todos, { text: "Add milk" }],
4140
notes: "Hello, team!",
4241
});
4342

4443
// Or mutate a draft (Immer-style)
45-
await store.setState((s) => {
44+
store.setState((s) => {
4645
s.todos.push({ text: "Ship" });
4746
s.settings.title = "Project";
4847
});
@@ -82,7 +81,7 @@ Trees are advanced usage; see Advanced: Trees at the end.
8281
- inferOptions: `{ defaultLoroText?: boolean; defaultMovableList?: boolean }` for container inference when schema is missing
8382
- Methods:
8483
- getState(): Current state
85-
- setState(updater | partial, options?): Mutate a draft or return a new object. Returns a Promise and should be `await`ed in non-React code when you need the update applied before the next line.
84+
- setState(updater | partial, options?): Mutate a draft or return a new object. Runs synchronously so downstream logic can immediately read the latest state.
8685
- options: `{ tags?: string | string[] }` (surfaces in subscriber metadata)
8786
- subscribe((state, metadata) => void): Subscribe; returns unsubscribe
8887
- metadata: `{ direction: FROM_LORO | TO_LORO; tags?: string[] }`
@@ -134,7 +133,7 @@ const node = schema.LoroMap({ name: schema.String({ required: true }) });
134133
const s = schema({ tree: schema.LoroTree(node) });
135134
const mirror = new Mirror({ doc: new LoroDoc(), schema: s });
136135

137-
await mirror.setState((st) => {
136+
mirror.setState((st) => {
138137
st.tree.push({ data: { name: "root" }, children: [] });
139138
});
140139
```

packages/core/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
"build": "tsc -p .",
2525
"test": "vitest run",
2626
"test:watch": "vitest",
27-
"lint": "eslint src --ext .ts,.tsx",
2827
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit"
2928
},
3029
"keywords": [
@@ -41,7 +40,7 @@
4140
"immer": "^10.0.3"
4241
},
4342
"peerDependencies": {
44-
"loro-crdt": "^1.7.1"
43+
"loro-crdt": "^1.8.0"
4544
},
4645
"devDependencies": {
4746
"@types/node": "^20.10.5",

packages/core/src/core/loroEventApply.test.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,13 @@ import { describe, it, expect } from "vitest";
33
import { LoroDoc, LoroText, LoroList, LoroMap, LoroCounter } from "loro-crdt";
44
import { applyEventBatchToState } from "./loroEventApply";
55

6-
const commitAndAssert = async (
7-
doc: LoroDoc,
8-
getState: () => unknown,
9-
) => {
6+
const commitAndAssert = (doc: LoroDoc, getState: () => unknown) => {
107
doc.commit();
11-
// allow microtask queue to flush if needed
12-
await Promise.resolve();
138
expect(getState()).toEqual(doc.toJSON());
149
};
1510

1611
describe("applyEventBatchToState (inline)", () => {
17-
it("syncs map primitives", async () => {
12+
it("syncs map primitives", () => {
1813
const doc = new LoroDoc();
1914
let state: Record<string, unknown> = {};
2015
const unsub = doc.subscribe((b) => {
@@ -25,13 +20,13 @@ describe("applyEventBatchToState (inline)", () => {
2520

2621
const m = doc.getMap("m");
2722
m.set("a", 1);
28-
await commitAndAssert(doc, () => state);
23+
commitAndAssert(doc, () => state);
2924

3025
m.set("b", 2);
31-
await commitAndAssert(doc, () => state);
26+
commitAndAssert(doc, () => state);
3227

3328
m.delete("a");
34-
await commitAndAssert(doc, () => state);
29+
commitAndAssert(doc, () => state);
3530

3631
unsub();
3732
});
@@ -600,7 +595,9 @@ describe("applyEventBatchToState (inline)", () => {
600595
};
601596

602597
const textOp = () => {
603-
const t = chance(0.5) ? texts[rand(texts.length)] : nestedTexts[rand(nestedTexts.length)] || texts[0];
598+
const t = chance(0.5)
599+
? texts[rand(texts.length)]
600+
: nestedTexts[rand(nestedTexts.length)] || texts[0];
604601
if (t.isDeleted()) {
605602
return;
606603
}
@@ -715,7 +712,7 @@ function normalize(i: Record<string, unknown>): Record<string, unknown> {
715712
}
716713
} else if (typeof v === "number") {
717714
if (v === 0) {
718-
delete s[k]
715+
delete s[k];
719716
}
720717
} else if (typeof v === "string") {
721718
if (v === "") {

packages/core/src/core/mirror.ts

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1689,45 +1689,41 @@ export class Mirror<S extends SchemaType> {
16891689
/**
16901690
* Update state and propagate changes to Loro.
16911691
*
1692-
* NOTE: You should await this method
1693-
*
16941692
* - If `updater` is an object, it will shallow-merge into the current state.
16951693
* - If `updater` is a function, it may EITHER:
16961694
* - mutate a draft (Immer-style), OR
16971695
* - return a brand new immutable state object.
16981696
*
16991697
* This supports both immutable and mutative update styles without surprises.
17001698
*/
1701-
async setState(
1699+
setState(
17021700
updater: (state: Readonly<InferInputType<S>>) => InferInputType<S>,
17031701
options?: SetStateOptions,
1704-
): Promise<void>;
1705-
async setState(
1702+
): void;
1703+
setState(
17061704
updater: (state: InferType<S>) => void,
17071705
options?: SetStateOptions,
1708-
): Promise<void>;
1709-
async setState(
1706+
): void;
1707+
setState(
17101708
updater: Partial<InferInputType<S>>,
17111709
options?: SetStateOptions,
1712-
): Promise<void>;
1713-
async setState(
1710+
): void;
1711+
setState(
17141712
updater:
17151713
| ((state: InferType<S>) => InferType<S> | InferInputType<S> | void)
17161714
| ((state: Readonly<InferInputType<S>>) => InferInputType<S>)
17171715
| Partial<InferInputType<S>>,
17181716
options?: SetStateOptions,
1719-
): Promise<void> {
1717+
): void {
17201718
if (this.syncing) return; // Prevent recursive updates
1721-
// Wait for the existing Loro event to be handled
1722-
await Promise.resolve();
17231719
// Calculate new state; support mutative or return-based updater via Immer
17241720
const newState =
17251721
typeof updater === "function"
17261722
? produce<InferType<S>>(this.state, (draft) => {
17271723
const res = (
1728-
updater as (state: InferType<S>) =>
1729-
| InferType<S>
1730-
| void
1724+
updater as (
1725+
state: InferType<S>,
1726+
) => InferType<S> | void
17311727
)(draft as InferType<S>);
17321728
if (res && res !== (draft as unknown)) {
17331729
// Return a replacement so Immer finalizes it
@@ -1741,7 +1737,7 @@ export class Mirror<S extends SchemaType> {
17411737
) as InferType<S>);
17421738

17431739
// Validate state if needed
1744-
// TODO: REVIEW We don't need to validate the state that are already reviewed
1740+
// TODO: We don't need to validate the state that are already reviewed
17451741
if (this.options.validateUpdates) {
17461742
const validation =
17471743
this.schema && validateSchema(this.schema, newState);

packages/core/tests/cid.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -266,17 +266,17 @@ describe("$cid: state injection and write ignoring (always-on for LoroMap)", ()
266266
expect(after).toBe(before);
267267
});
268268

269-
it("TO_LORO + consistency check: changing $cid throws divergence error", async () => {
269+
it("TO_LORO + consistency check: changing $cid throws divergence error", () => {
270270
const doc = new LoroDoc();
271271
const s = schema({ m: schema.LoroMap({ label: schema.String() }) });
272272
const m = new Mirror({ doc, schema: s, checkStateConsistency: true });
273273

274-
await expect(
274+
expect(() => {
275275
m.setState((draft: any) => {
276276
draft.m.label = "ok";
277277
draft.m[CID_KEY] = "tamper"; // should be ignored to Loro and trigger consistency error
278-
}),
279-
).rejects.toThrow();
278+
});
279+
}).toThrow();
280280
});
281281

282282
it("list items: $cid values exist and are unique per item", async () => {

packages/core/tests/mirror-movable-list.test.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const waitForSync = async () => {
1313
};
1414

1515
describe("MovableList", () => {
16-
async function initTestMirror() {
16+
function initTestMirror() {
1717
const doc = new LoroDoc();
1818
doc.setPeerId(1);
1919
const schema_ = schema({
@@ -31,7 +31,7 @@ describe("MovableList", () => {
3131
schema: schema_,
3232
});
3333

34-
await mirror.setState({
34+
mirror.setState({
3535
list: [
3636
{
3737
id: "1",
@@ -40,8 +40,6 @@ describe("MovableList", () => {
4040
],
4141
});
4242

43-
await waitForSync();
44-
4543
return { mirror, doc };
4644
}
4745

@@ -536,16 +534,16 @@ describe("MovableList", () => {
536534
} as any);
537535
});
538536

539-
it("movable list throws on duplicate ids in new state", async () => {
540-
const { mirror } = await initTestMirror();
541-
await expect(
537+
it("movable list throws on duplicate ids in new state", () => {
538+
const { mirror } = initTestMirror();
539+
expect(() => {
542540
mirror.setState({
543541
list: [
544542
{ id: "X", text: "1" },
545543
{ id: "X", text: "2" },
546544
],
547-
}),
548-
).rejects.toThrow();
545+
});
546+
}).toThrow();
549547
});
550548

551549
it("movable list fuzz: large shuffles preserve container ids and text", async () => {

packages/core/tests/mirror-tree.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -974,27 +974,27 @@ describe("LoroTree integration", () => {
974974
// sanity: ensure format looks like `${number}@${peer}`
975975
expect(nodeIds.every((id: string) => /@/.test(id))).toBe(true);
976976
});
977-
it("TO_LORO: invalid tree value (non-array) throws validation error", async () => {
977+
it("TO_LORO: invalid tree value (non-array) throws validation error", () => {
978978
const doc = new LoroDoc();
979979
const s = schema({
980980
tree: schema.LoroTree(schema.LoroMap({ title: schema.String() })),
981981
});
982982
const m = new Mirror({ doc, schema: s });
983983

984-
await expect(
984+
expect(() => {
985985
m.setState({
986986
tree: { id: "", data: { title: "X" } },
987-
} as any),
988-
).rejects.toThrow();
987+
} as any);
988+
}).toThrow();
989989
});
990-
it("TO_LORO: invalid node shape (children not array) throws", async () => {
990+
it("TO_LORO: invalid node shape (children not array) throws", () => {
991991
const doc = new LoroDoc();
992992
const s = schema({
993993
tree: schema.LoroTree(schema.LoroMap({ title: schema.String() })),
994994
});
995995
const m = new Mirror({ doc, schema: s });
996996

997-
await expect(
997+
expect(() => {
998998
m.setState({
999999
tree: [
10001000
{
@@ -1003,8 +1003,8 @@ describe("LoroTree integration", () => {
10031003
children: "oops",
10041004
},
10051005
],
1006-
} as any),
1007-
).rejects.toThrow();
1006+
} as any);
1007+
}).toThrow();
10081008
});
10091009

10101010
// Nested tree container inside a map

0 commit comments

Comments
 (0)