Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wasm-kind-cache.md
Original file line number Diff line number Diff line change
@@ -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()`.
12 changes: 10 additions & 2 deletions context/wasm-container-id-cache.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down
30 changes: 30 additions & 0 deletions crates/loro-wasm/scripts/container_id_cache_patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -65,4 +94,5 @@ for (const ContainerClass of [
LoroCounter,
]) {
__loroCacheContainerIdGetter(ContainerClass);
__loroCacheKindMethod(ContainerClass);
}
7 changes: 7 additions & 0 deletions crates/loro-wasm/scripts/measure-container-id.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -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++;
}
Expand All @@ -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],
Expand Down
48 changes: 48 additions & 0 deletions crates/loro-wasm/tests/container_id_cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Loading