diff --git a/.changeset/wasm-kind-cache.md b/.changeset/wasm-kind-cache.md new file mode 100644 index 000000000..007e6ea4f --- /dev/null +++ b/.changeset/wasm-kind-cache.md @@ -0,0 +1,5 @@ +--- +"loro-crdt": patch +--- + +perf: cache `kind()` results on container wrappers. `kind()` returns a constant string per container class; it is now memoized after the first call, so repeated reads (e.g. tree traversal in loro-mirror) no longer cross into WASM or allocate a fresh JS string. This extends the existing `id` cache to `kind()`. diff --git a/context/wasm-container-id-cache.md b/context/wasm-container-id-cache.md index a1ae21a85..e1dfaebbb 100644 --- a/context/wasm-container-id-cache.md +++ b/context/wasm-container-id-cache.md @@ -1,10 +1,11 @@ # WASM container id cache -Verified against code 2026-08-29. +Verified against code 2026-09-04. `LoroMap`, `LoroList`, `LoroText`, `LoroTree`, `LoroMovableList`, and `LoroCounter` expose `id` from Rust through wasm-bindgen. The generated getter -crosses into WASM and creates a new JS string on every call. +crosses into WASM and creates a new JS string on every call. The same holds for +`kind()`, which returns a constant string per container class. ## Identity and lifetime @@ -26,6 +27,13 @@ property during `__destroy_into_raw()` and bypasses the cache for a zero pointer. The cache has exactly the wrapper's lifetime and does not retain the wrapper from another object. +`kind()` is memoized once per container class (the value is class-constant, so +no per-wrapper entry is needed). Both the id cache and the kind memo bypass the +cache for a zero `__wbg_ptr`, so reads after `free()` keep raising +wasm-bindgen's null-pointer error. Rust-side `kind()` reads +(`js_to_container` in `src/convert.rs`) go through the same patched prototype +method and share the memo. + ## Package targets and checks `scripts/build.ts` appends the cache patch to the raw wasm-bindgen module for diff --git a/crates/loro-wasm/scripts/container_id_cache_patch.js b/crates/loro-wasm/scripts/container_id_cache_patch.js index 09ffd3e3f..c5a7339e6 100644 --- a/crates/loro-wasm/scripts/container_id_cache_patch.js +++ b/crates/loro-wasm/scripts/container_id_cache_patch.js @@ -56,6 +56,35 @@ function __loroCacheContainerIdGetter(ContainerClass) { }); } +// `kind()` returns a constant string per container class and never depends on +// instance state, so one module-level memo per class is enough. Repeated reads +// (e.g. loro-mirror's traversal) must not cross into WASM again. +function __loroCacheKindMethod(ContainerClass) { + const prototype = ContainerClass.prototype; + const kindDescriptor = Object.getOwnPropertyDescriptor(prototype, "kind"); + const kind = kindDescriptor && kindDescriptor.value; + + if (typeof kind !== "function") { + throw new Error("Unexpected wasm-bindgen container wrapper shape"); + } + + let cached; + Object.defineProperty(prototype, "kind", { + ...kindDescriptor, + value() { + // Preserve wasm-bindgen's post-free error instead of returning stale data. + if (this.__wbg_ptr === 0) { + return kind.call(this); + } + + if (cached === undefined) { + cached = kind.call(this); + } + return cached; + }, + }); +} + for (const ContainerClass of [ LoroMap, LoroText, @@ -65,4 +94,5 @@ for (const ContainerClass of [ LoroCounter, ]) { __loroCacheContainerIdGetter(ContainerClass); + __loroCacheKindMethod(ContainerClass); } diff --git a/crates/loro-wasm/scripts/measure-container-id.cjs b/crates/loro-wasm/scripts/measure-container-id.cjs index de42d51a9..3626e4623 100644 --- a/crates/loro-wasm/scripts/measure-container-id.cjs +++ b/crates/loro-wasm/scripts/measure-container-id.cjs @@ -68,6 +68,10 @@ const repeatedRun = ({ container }) => { for (let i = 0; i < REPEATED_READS; i++) blackhole += container.id.length; }; +const repeatedKindRun = ({ container }) => { + for (let i = 0; i < REPEATED_READS; i++) blackhole += container.kind().length; +}; + const firstRun = ({ containers }) => { for (const container of containers) blackhole += container.id.length; }; @@ -78,6 +82,8 @@ const mirrorReuseRun = ({ containers }) => { blackhole += container.id.length; blackhole += container.id.length; blackhole += container.id.length; + blackhole += container.kind().length; + blackhole += container.kind().length; const json = container.toJSON(); if (json != null) blackhole++; } @@ -99,6 +105,7 @@ const churnRun = ({ doc, ids }) => { const cases = [ ["same-wrapper-repeated-id", repeatedSetup, repeatedRun], + ["same-wrapper-repeated-kind", repeatedSetup, repeatedKindRun], ["distinct-wrappers-first-id", makeContainers, firstRun], ["mirror-reuse-wrapper", makeContainers, mirrorReuseRun], ["mirror-new-wrapper-per-id", churnSetup, churnRun], diff --git a/crates/loro-wasm/tests/container_id_cache.test.ts b/crates/loro-wasm/tests/container_id_cache.test.ts index 50033ee7a..bc5b2ac0d 100644 --- a/crates/loro-wasm/tests/container_id_cache.test.ts +++ b/crates/loro-wasm/tests/container_id_cache.test.ts @@ -112,3 +112,51 @@ describe("container id cache", () => { text.free(); }); }); + +describe("container kind cache", () => { + it("returns the same kind string on repeated calls without re-decoding", () => { + const doc = new LoroDoc(); + const first = doc.getMap("a"); + const second = doc.getMap("b"); + // Warm the class-level memo; kind() is constant per container class. + first.kind(); + + const { result: kinds, decoderCalls } = countTextDecodes(() => [ + first.kind(), + first.kind(), + second.kind(), + ]); + + expect(kinds).toEqual(["Map", "Map", "Map"]); + expect(decoderCalls).toBe(0); + }); + + it("decodes kind() at most once per container class", () => { + const doc = new LoroDoc(); + const containers: Container[] = [ + doc.getMap("map"), + doc.getText("text"), + doc.getList("list"), + doc.getTree("tree"), + doc.getMovableList("movable-list"), + doc.getCounter("counter"), + ]; + + const { result: kinds, decoderCalls } = countTextDecodes(() => + containers.map((container) => [container.kind(), container.kind()]), + ); + + expect(decoderCalls).toBeLessThanOrEqual(containers.length); + for (const [first, second] of kinds) { + expect(second).toBe(first); + } + }); + + it("does not return a cached kind after free", () => { + const text = new LoroText(); + expect(text.kind()).toBe("Text"); + text.free(); + + expect(() => text.kind()).toThrow("null pointer passed to rust"); + }); +});