Skip to content

Commit a34134d

Browse files
authored
perf: rm thin wrapper for ops that will not trigger event (#894)
* perf: rm the event calling wrapper for ops that will not trigger events * chore: changeset * fix: revertTo and applyDiff * fix: getCursorPos * fix: add setDetachedEditing
1 parent 76d22d6 commit a34134d

File tree

6 files changed

+196
-8
lines changed

6 files changed

+196
-8
lines changed

.changeset/mighty-ligers-dance.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"loro-crdt": patch
3+
"loro-crdt-map": patch
4+
---
5+
6+
perf: rm the event calling wrapper for ops that will not trigger events

AGENTS.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Agent Notes (Loro)
2+
3+
## Invariant: Flush Pending Events In `loro-wasm`
4+
5+
In `crates/loro-wasm/src/lib.rs`, subscription callbacks (`subscribe*`, container `subscribe`, etc.)
6+
do not call user JS immediately. Instead, the binding enqueues JS calls into a global pending queue,
7+
and schedules a microtask check. If the microtask runs before `callPendingEvents()` flushes the
8+
queue, it will log:
9+
10+
- `[LORO_INTERNAL_ERROR] Event not called`
11+
12+
This creates a strict invariant:
13+
14+
- **Any WASM-exposed API that can enqueue subscription events must flush pending events before
15+
returning control back to JS.**
16+
17+
To avoid adding overhead to every single op, we only wrap (decorate) a small allowlist of
18+
methods on the JS side. The wrapper calls `callPendingEvents()` in a `finally` block.
19+
20+
### How To Maintain
21+
22+
- When adding or changing a `#[wasm_bindgen]` API in `crates/loro-wasm/src/lib.rs` that can
23+
*mutate document state*:
24+
- If it can trigger an implicit commit / barrier (`commit`, `with_barrier` /
25+
`implicit_commit_then_stop`), emit events (`emit_events`), or applies diffs (e.g. `revertTo`,
26+
`applyDiff`), it typically **must** flush pending events.
27+
- Add its JS name to the allowlist in `crates/loro-wasm/index.ts` near the bottom:
28+
`decorateMethods(LoroDoc.prototype, [...])` (or the relevant prototype allowlist).
29+
- If it is a pure read/query API (no state mutation, no commit/barrier, no event emission),
30+
do **not** decorate it, to avoid unnecessary per-call cost.
31+
32+
### Quick Check
33+
34+
With active subscriptions (`doc.subscribe(...)` / container `subscribe(...)`), calling mutating APIs
35+
should not produce the error above. A recommended local check is:
36+
37+
```sh
38+
pnpm -C crates/loro-wasm build-release
39+
```

crates/loro-wasm/index.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,36 @@ function decorateAllPrototypeMethods(prototype: object) {
491491
}
492492
}
493493

494-
decorateAllPrototypeMethods(LoroDoc.prototype);
495-
decorateAllPrototypeMethods(EphemeralStoreWasm.prototype);
496-
decorateAllPrototypeMethods(AwarenessWasm.prototype);
494+
decorateMethods(LoroDoc.prototype, [
495+
"setDetachedEditing",
496+
"attach",
497+
"detach",
498+
"fork",
499+
"forkAt",
500+
"checkoutToLatest",
501+
"checkout",
502+
"commit",
503+
"getCursorPos",
504+
"revertTo",
505+
"export",
506+
"exportJsonUpdates",
507+
"exportJsonInIdSpan",
508+
"importJsonUpdates",
509+
"import",
510+
"importUpdateBatch",
511+
"importBatch",
512+
"travelChangeAncestors",
513+
"getChangedContainersIn",
514+
"diff",
515+
"applyDiff",
516+
"setPeerId",
517+
]);
518+
519+
decorateMethods(EphemeralStoreWasm.prototype, [
520+
"set",
521+
"delete",
522+
"apply",
523+
"removeOutdated",
524+
]);
525+
497526
decorateMethods(UndoManager.prototype, ["undo", "redo"]);

crates/loro-wasm/src/lib.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ use parking_lot::lock_api::ReentrantMutex;
3535
use rle::HasLength;
3636
use serde::{Deserialize, Serialize};
3737
use std::{
38-
cell::RefCell,
38+
cell::{Cell, RefCell},
3939
cmp::Ordering,
4040
collections::VecDeque,
4141
ops::ControlFlow,
@@ -89,6 +89,10 @@ pub fn set_debug() {
8989
type JsResult<T> = Result<T, JsValue>;
9090
type EventCallback = Box<dyn Fn(&SafeJsValue) -> bool + Send + Sync + 'static>;
9191

92+
thread_local! {
93+
static IN_PRE_COMMIT_CALLBACK: Cell<bool> = Cell::new(false);
94+
}
95+
9296
/// The CRDTs document. Loro supports different CRDTs include [**List**](LoroList),
9397
/// [**RichText**](LoroText), [**Map**](LoroMap) and [**Movable Tree**](LoroTree),
9498
/// you could build all kind of applications by these.
@@ -1508,7 +1512,15 @@ impl LoroDoc {
15081512
#[wasm_bindgen(js_name = "exportJsonInIdSpan", skip_typescript)]
15091513
pub fn exportJsonInIdSpan(&self, idSpan: JsIdSpan) -> JsResult<JsValue> {
15101514
let id_span = js_to_id_span(idSpan)?;
1511-
let json = self.doc.export_json_in_id_span(id_span);
1515+
// Most LoroDoc reads run in an implicit-commit barrier (export/checkout/etc.).
1516+
// `exportJsonInIdSpan` is special: it is often called from `subscribePreCommit`,
1517+
// where the txn lock is already held. In that case, triggering another implicit
1518+
// commit would deadlock/panic. We skip the barrier while inside pre-commit.
1519+
let json = if IN_PRE_COMMIT_CALLBACK.with(|f| f.get()) {
1520+
self.doc.export_json_in_id_span(id_span)
1521+
} else {
1522+
self.doc.with_barrier(|| self.doc.export_json_in_id_span(id_span))
1523+
};
15121524
let s = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true);
15131525
let v = json
15141526
.serialize(&s)
@@ -2235,7 +2247,13 @@ impl LoroDoc {
22352247
&ChangeModifier(e.modifier.clone()).into(),
22362248
)
22372249
.unwrap();
2238-
if let Err(e) = observer.call1(&obj.into()) {
2250+
let res = IN_PRE_COMMIT_CALLBACK.with(|f| {
2251+
let prev = f.replace(true);
2252+
let res = observer.call1(&obj.into());
2253+
f.set(prev);
2254+
res
2255+
});
2256+
if let Err(e) = res {
22392257
console_error!("Error: {:?}", e);
22402258
}
22412259
true
@@ -6626,7 +6644,9 @@ interface LoroDoc<T extends Record<string, Container> = Record<string, Container
66266644
*
66276645
* This method can also export pending changes from the uncommitted transaction that have not yet been applied to the OpLog.
66286646
*
6629-
* This method will NOT trigger a new commit implicitly.
6647+
* This method will implicitly commit pending local operations (like `export(...)`) so callers can
6648+
* observe the latest local edits. When called inside `subscribePreCommit(...)`, it will NOT trigger
6649+
* an additional implicit commit.
66306650
*
66316651
* @param idSpan - The id span to export.
66326652
* @returns The changes in the given id span.

crates/loro-wasm/tests/basic.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1244,6 +1244,41 @@ it("export json in id span #602", () => {
12441244
}
12451245
});
12461246

1247+
it("export json in id span implicitly commits pending ops", () => {
1248+
const doc = new LoroDoc();
1249+
doc.setPeerId("1");
1250+
doc.getText("text").insert(0, "Hello");
1251+
expect(doc.getPendingTxnLength()).toBe(5);
1252+
1253+
const changes = doc.exportJsonInIdSpan({
1254+
peer: "1",
1255+
counter: 0,
1256+
length: 5,
1257+
});
1258+
expect(changes).toStrictEqual([
1259+
{
1260+
id: "0@1",
1261+
timestamp: expect.any(Number),
1262+
deps: [],
1263+
lamport: 0,
1264+
msg: undefined,
1265+
ops: [
1266+
{
1267+
container: "cid:root-text:Text",
1268+
counter: 0,
1269+
content: {
1270+
type: "insert",
1271+
pos: 0,
1272+
text: "Hello",
1273+
},
1274+
},
1275+
],
1276+
},
1277+
]);
1278+
1279+
expect(doc.getPendingTxnLength()).toBe(0);
1280+
});
1281+
12471282
it("find spans between versions", () => {
12481283
const doc = new LoroDoc();
12491284
doc.setPeerId("1");

crates/loro-wasm/tests/event.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it } from "vitest";
1+
import { describe, expect, it, vi } from "vitest";
22
import crypto from "crypto";
33
import {
44
Delta,
@@ -537,6 +537,65 @@ describe("event", () => {
537537
expect(text2.toString()).toBe("1. Hello World!");
538538
});
539539
});
540+
541+
it("revertTo flushes pending events", async () => {
542+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
543+
try {
544+
const doc = new LoroDoc();
545+
doc.setPeerId("1");
546+
547+
let called = 0;
548+
doc.subscribe(() => {
549+
called += 1;
550+
});
551+
552+
doc.getText("text").update("Hello");
553+
doc.revertTo([]);
554+
await Promise.resolve();
555+
556+
expect(called).toBeGreaterThan(0);
557+
expect(
558+
errorSpy.mock.calls.some((args) =>
559+
args.some((arg) =>
560+
String(arg).includes("[LORO_INTERNAL_ERROR] Event not called"),
561+
),
562+
),
563+
).toBe(false);
564+
} finally {
565+
errorSpy.mockRestore();
566+
}
567+
});
568+
569+
it("setDetachedEditing flushes pending events", async () => {
570+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
571+
try {
572+
const doc = new LoroDoc();
573+
doc.setPeerId("1");
574+
575+
let called = 0;
576+
doc.subscribe(() => {
577+
called += 1;
578+
});
579+
580+
doc.detach();
581+
doc.setDetachedEditing(true);
582+
583+
doc.getText("text").update("Hello");
584+
doc.setDetachedEditing(true);
585+
await Promise.resolve();
586+
587+
expect(called).toBeGreaterThan(0);
588+
expect(
589+
errorSpy.mock.calls.some((args) =>
590+
args.some((arg) =>
591+
String(arg).includes("[LORO_INTERNAL_ERROR] Event not called"),
592+
),
593+
),
594+
).toBe(false);
595+
} finally {
596+
errorSpy.mockRestore();
597+
}
598+
});
540599
});
541600

542601
it.skip("subscription works after timeout", async () => {

0 commit comments

Comments
 (0)