Hi o7, I'm Steven, building Scribsy, an IDE and semantic second brain for creative writers with Loro as the document backbone. We hit this while fuzz-testing anchor durability ahead of adopting LoroTree as our manuscript spine (scenes as tree nodes). The repro below is standalone, deterministic, and depends only on the published package.
Also want to say that the Cursor API and shallow snapshots are exactly the primitives a long-manuscript editor needs, and the 1.13.x performance work was noticeable immediately, specifically the O(n²) fixes in 1.13.4 and the snapshot-import work in 1.13.5.
Environment
loro-crdt: 1.13.7 (npm), installed via npm install loro-crdt@1.13.7
(resolved: https://registry.npmjs.org/loro-crdt/-/loro-crdt-1.13.7.tgz,
integrity: sha512-tOvBUBSVseebXx8L5TMEdVYwgTOVZMMUtA9tQAs2vpPNRx/hClsLGhjmo9D3J4LopCVrQ6cuj3ZdBeyxQheWAg==).
Our project's lockfile pins this exact version/integrity; the run below used that installed copy rather than a fresh install into an empty directory, but the resolved tarball and integrity hash are the same either way.
- Node.js:
v22.23.1
- OS/arch: Ubuntu 24.04.4 LTS (Noble Numbat), kernel
6.17.0-1020-azure, x86_64
- Reproduced with
LoroDoc/UndoManager only -- no framework/bundler involved.
Summary
Across four independent legs (control, disjoint-import, top-level-map, fresh-undo-manager -- all four defined in the single script below), a specific condition reproduces reliably: after a remote import() lands, undo()-ing the next local commit that edits a LoroTree node's data does not restore the pre-edit content under the node's original TreeID. isNodeDeleted() reports true for that TreeID afterward, and doc.toJSON() shows no node with that id -- the same content that was there before the edit ("hello") is instead found in the post-undo tree under a different TreeID.
This reproduces even when:
- the imported remote edit is fully disjoint from the node being edited/undone (a brand new, unrelated node -- never touching the node in question at all), and
- the import is not concurrent with the local commit being undone (the import lands and is fully applied before the local edit is even made -- plain sequential history, no merge/conflict in play), and
- a brand-new
UndoManager, constructed strictly after the import (so it never observed any pre-import state), is used instead of one that pre-dates the import.
A disjoint remote update imported before a subsequent local tree-node edit is sufficient to reproduce this; we have not tested whether it is necessary.
A control run with the identical local-edit-then-undo sequence but no import in between shows the original TreeID correctly retained, with matching content. A second control run against a top-level LoroMap (not a LoroTree), with the same import-then-edit-then-undo shape, also behaves correctly. See the tested-matrix table below.
Every handle used to check post-import / post-undo state in the script below (tree, node, text container) is reacquired fresh by TreeID after import() -- none are reused from before the import -- so this is not an artifact of holding a stale JavaScript wrapper object.
Related issues
Together the three reports seem to triangulate UndoManager × LoroTree node/container identity under reconstruction (import or redo) rather than being independent one-offs.
Tested matrix
| Leg |
Shape |
Result |
| control |
LoroTree, no import |
PASS |
| disjoint-import |
LoroTree, disjoint remote import before the local edit under test |
FAIL |
| top-level-map |
LoroMap (not LoroTree), same import-then-edit-then-undo shape |
PASS |
| fresh-undo-manager |
LoroTree, UndoManager constructed strictly after the import |
FAIL |
(PASS/FAIL here means: does undo() restore the pre-edit content under the
original id, per the assertions printed in the script's own output below.)
Minimal repro
Single file, two LoroDoc replicas, export({mode:"update"})/import(), undo()
calls, plain assertions. Depends only on the published loro-crdt package. Run:
npm install loro-crdt@1.13.7 && npx tsx loro-undo-after-import-repro.ts.
// Minimal, self-contained repro for a loro-crdt 1.13.7 UndoManager observation.
//
// Depends only on the published "loro-crdt" package -- no internal harness/project code.
//
// Run: npx tsx loro-undo-after-import-repro.ts (after `npm install loro-crdt@1.13.7`)
//
// Design notes:
// - Every handle used to check post-import / post-undo state (tree, node, text
// container) is REACQUIRED fresh after import() -- never reused from before the
// import. This forecloses a "you're just holding a stale JS wrapper" dismissal.
// - Four independent legs, each its own function with its own assertions:
// (a) control -- LoroTree, no import at all (expected PASS)
// (b) disjoint-import -- LoroTree, a disjoint remote import lands before the
// local edit under test (the main repro condition)
// (c) top-level-map -- identical shape, but on a top-level LoroMap instead
// of a LoroTree node (isolates whether this is Tree-
// specific)
// (d) fresh-undo-manager -- LoroTree, UndoManager constructed AFTER the import,
// so it never observed any pre-import state at all
// - In every leg, node/map creation is committed BEFORE that leg's UndoManager is
// even constructed, so creation can never be part of the group under test -- this
// is unconditional, not just "the group we didn't call undo() on again".
// - Whatever this prints is the result. This file does not assert or narrate a
// mechanism (e.g. "identity reassignment") -- only what the API reports.
//
// Exit code: nonzero iff any TREE-LEG defect assertion (node-deleted / content-
// restored, i.e. legs a, b, d) fails. The map leg (c) is an isolation control and does
// not gate the exit code by itself -- see the printed MATRIX line for its result too.
import { LoroDoc, UndoManager } from "loro-crdt";
type LegKind = "tree" | "map";
type LegAcc = { ok: boolean };
const legSummaries: { name: string; pass: boolean }[] = [];
let anyTreeDefectAssertionFailed = false;
function assertEqual(
actual: unknown,
expected: unknown,
label: string,
opts: { leg: LegKind; isDefectAssertion: boolean; legAcc: LegAcc },
): void {
const ok = actual === expected;
console.log(
`${ok ? "OK " : "FAIL"} ${label}: actual=${JSON.stringify(actual)} expected=${JSON.stringify(expected)}`,
);
if (!ok) {
opts.legAcc.ok = false;
if (opts.leg === "tree" && opts.isDefectAssertion) anyTreeDefectAssertionFailed = true;
}
}
function treeNodeIds(doc: LoroDoc): string[] {
return doc
.getTree("tree")
.getNodes({ withDeleted: true })
.map((n) => n.id)
.sort();
}
// B forks from A's current state, makes a fully disjoint edit (a brand new tree node
// with its own text, never touching anything already in A), and A imports it. This is
// deliberately NOT concurrent with, and NOT touching, whatever in A is under test.
function importDisjointRemoteEdit(a: LoroDoc): void {
const snapshot = a.export({ mode: "snapshot" });
const forkVV = a.oplogVersion();
const b = new LoroDoc();
b.setPeerId(2);
b.import(snapshot);
const otherNode = b.getTree("tree").createNode();
otherNode.data.ensureMergeableText("body").insert(0, "unrelated content from B");
b.commit();
const updateFromB = b.export({ mode: "update", from: forkVV });
a.import(updateFromB); // <-- the only structural difference from the control leg
}
// ---------------------------------------------------------------------------
// (a) CONTROL -- LoroTree, no import in between.
// ---------------------------------------------------------------------------
function controlNoImport(): void {
const legAcc: LegAcc = { ok: true };
console.log("\n=== (a) CONTROL: LoroTree, same local edit + undo, NO import ===");
const a = new LoroDoc();
a.setPeerId(1);
const node = a.getTree("tree").createNode();
const treeId = node.id; // stored at creation -- this is the id checked at the end
a.commit(); // commits node creation ONLY -- the UndoManager below doesn't exist yet,
// so this commit can never be part of any group it undoes.
const undoManager = new UndoManager(a, { mergeInterval: 0 }); // <-- UndoManager construction point
const body = node.data.ensureMergeableText("body");
body.insert(0, "hello");
a.commit(); // first commit the UndoManager observes -- establishes the pre-edit baseline
const preText = body.toString();
const preEditJSON = a.toJSON();
body.insert(body.length, " world");
a.commit(); // <-- the commit under test: the ONE commit undo() below must revert
const undone = undoManager.undo();
const postUndoJSON = a.toJSON();
const postNodeIds = treeNodeIds(a);
// No import happened in this leg, but the check is still done by TreeID lookup
// (not by continuing to use the `node`/`body` handles from above), matching the
// discipline used in the legs that do import.
const deleted = a.getTree("tree").isNodeDeleted(treeId);
const freshNode = a.getTree("tree").getNodeByID(treeId);
const postText = deleted || !freshNode ? "<deleted>" : freshNode.data.ensureMergeableText("body").toString();
console.log(`pre-edit doc.toJSON(): ${JSON.stringify(preEditJSON)}`);
console.log(`post-undo doc.toJSON(): ${JSON.stringify(postUndoJSON)}`);
console.log(`post-undo tree node ids: ${JSON.stringify(postNodeIds)}`);
assertEqual(undone, true, "undo() return value", { leg: "tree", isDefectAssertion: false, legAcc });
assertEqual(deleted, false, "node deleted after undo", { leg: "tree", isDefectAssertion: true, legAcc });
assertEqual(postText, preText, "text content after undo", { leg: "tree", isDefectAssertion: true, legAcc });
legSummaries.push({ name: "control", pass: legAcc.ok });
}
// ---------------------------------------------------------------------------
// (b) DISJOINT-IMPORT -- LoroTree, unrelated import lands before the edit under test.
// This is the main repro condition.
// ---------------------------------------------------------------------------
function disjointImportTree(): void {
const legAcc: LegAcc = { ok: true };
console.log("\n=== (b) DISJOINT-IMPORT: LoroTree, unrelated import lands before the edit under test ===");
const a = new LoroDoc();
a.setPeerId(1);
const node = a.getTree("tree").createNode();
const treeId = node.id;
a.commit(); // node creation only, committed before the UndoManager exists
const undoManager = new UndoManager(a, { mergeInterval: 0 }); // <-- UndoManager construction point
// No import has happened yet, so this handle is still known-good.
const body = node.data.ensureMergeableText("body");
body.insert(0, "hello");
a.commit(); // pre-edit baseline commit
const preText = body.toString();
const preEditJSON = a.toJSON();
importDisjointRemoteEdit(a);
// Reacquire EVERYTHING fresh after the import -- tree, node, and text container.
// The `node` / `body` handles from before the import are never touched again.
const freshNode = a.getTree("tree").getNodeByID(treeId)!;
const freshBody = freshNode.data.ensureMergeableText("body");
freshBody.insert(freshBody.length, " world");
a.commit(); // <-- the commit under test: made strictly after the import, via a
// freshly reacquired handle
const undone = undoManager.undo();
const postUndoJSON = a.toJSON();
const postNodeIds = treeNodeIds(a);
const deleted = a.getTree("tree").isNodeDeleted(treeId);
const postNode = a.getTree("tree").getNodeByID(treeId);
const postText = deleted || !postNode ? "<deleted>" : postNode.data.ensureMergeableText("body").toString();
console.log(`pre-edit doc.toJSON(): ${JSON.stringify(preEditJSON)}`);
console.log(`post-undo doc.toJSON(): ${JSON.stringify(postUndoJSON)}`);
console.log(`post-undo tree node ids: ${JSON.stringify(postNodeIds)}`);
assertEqual(undone, true, "undo() return value", { leg: "tree", isDefectAssertion: false, legAcc });
assertEqual(deleted, false, "node deleted after undo", { leg: "tree", isDefectAssertion: true, legAcc });
assertEqual(postText, preText, "text content after undo", { leg: "tree", isDefectAssertion: true, legAcc });
legSummaries.push({ name: "disjoint-import", pass: legAcc.ok });
}
// ---------------------------------------------------------------------------
// (c) TOP-LEVEL-MAP -- identical shape, but on a top-level LoroMap, not a LoroTree
// node. Isolation check: does the same import-then-edit-then-undo sequence misbehave
// on a non-Tree container?
// ---------------------------------------------------------------------------
function topLevelMapControl(): void {
const legAcc: LegAcc = { ok: true };
console.log("\n=== (c) TOP-LEVEL-MAP: LoroMap (not Tree), same import-then-edit-then-undo shape ===");
const a = new LoroDoc();
a.setPeerId(1);
const map = a.getMap("map");
a.commit(); // commits map creation only, before the UndoManager exists
const undoManager = new UndoManager(a, { mergeInterval: 0 }); // <-- UndoManager construction point
const body = map.ensureMergeableText("body");
body.insert(0, "hello");
a.commit(); // pre-edit baseline commit
const preText = body.toString();
const preEditJSON = a.toJSON();
importDisjointRemoteEdit(a);
// Reacquire fresh after import, same discipline as the tree legs.
const freshBody = a.getMap("map").ensureMergeableText("body");
freshBody.insert(freshBody.length, " world");
a.commit(); // <-- the commit under test
const undone = undoManager.undo();
const postUndoJSON = a.toJSON();
const postBody = a.getMap("map").ensureMergeableText("body");
const postText = postBody.toString();
console.log(`pre-edit doc.toJSON(): ${JSON.stringify(preEditJSON)}`);
console.log(`post-undo doc.toJSON(): ${JSON.stringify(postUndoJSON)}`);
assertEqual(undone, true, "undo() return value", { leg: "map", isDefectAssertion: false, legAcc });
assertEqual(postText, preText, "text content after undo", { leg: "map", isDefectAssertion: false, legAcc });
legSummaries.push({ name: "top-level-map", pass: legAcc.ok });
}
// ---------------------------------------------------------------------------
// (d) FRESH-UNDO-MANAGER -- LoroTree, UndoManager constructed AFTER the import, so it
// never observed pre-import state at all. Tests whether the defect is a property of
// UndoManager's own accumulated stack state, or of the doc's import history generally.
// ---------------------------------------------------------------------------
function freshUndoManagerAfterImport(): void {
const legAcc: LegAcc = { ok: true };
console.log("\n=== (d) FRESH-UNDO-MANAGER: LoroTree, UndoManager constructed AFTER the import ===");
const a = new LoroDoc();
a.setPeerId(1);
const node = a.getTree("tree").createNode();
const treeId = node.id;
a.commit(); // node creation only -- no UndoManager exists for this doc yet at all
const body = node.data.ensureMergeableText("body");
body.insert(0, "hello");
a.commit(); // pre-edit baseline commit -- still no UndoManager exists yet
const preText = body.toString();
const preEditJSON = a.toJSON();
importDisjointRemoteEdit(a);
// UndoManager is constructed HERE, strictly after the import -- it has never
// observed pre-import state, so if the defect still triggers it cannot be explained
// by anything cached in the UndoManager's own stack from before the import.
const undoManager = new UndoManager(a, { mergeInterval: 0 }); // <-- UndoManager construction point
// Reacquire fresh after import, same discipline as leg (b).
const freshNode = a.getTree("tree").getNodeByID(treeId)!;
const freshBody = freshNode.data.ensureMergeableText("body");
freshBody.insert(freshBody.length, " world");
a.commit(); // <-- the only commit this fresh UndoManager has ever tracked
const undone = undoManager.undo();
const postUndoJSON = a.toJSON();
const postNodeIds = treeNodeIds(a);
const deleted = a.getTree("tree").isNodeDeleted(treeId);
const postNode = a.getTree("tree").getNodeByID(treeId);
const postText = deleted || !postNode ? "<deleted>" : postNode.data.ensureMergeableText("body").toString();
console.log(`pre-edit doc.toJSON(): ${JSON.stringify(preEditJSON)}`);
console.log(`post-undo doc.toJSON(): ${JSON.stringify(postUndoJSON)}`);
console.log(`post-undo tree node ids: ${JSON.stringify(postNodeIds)}`);
assertEqual(undone, true, "undo() return value", { leg: "tree", isDefectAssertion: false, legAcc });
assertEqual(deleted, false, "node deleted after undo", { leg: "tree", isDefectAssertion: true, legAcc });
assertEqual(postText, preText, "text content after undo", { leg: "tree", isDefectAssertion: true, legAcc });
legSummaries.push({ name: "fresh-undo-manager", pass: legAcc.ok });
}
controlNoImport();
disjointImportTree();
topLevelMapControl();
freshUndoManagerAfterImport();
const matrixLine = legSummaries.map((l) => `${l.name}=${l.pass ? "PASS" : "FAIL"}`).join(" ");
console.log(`\nMATRIX: ${matrixLine}`);
process.exitCode = anyTreeDefectAssertionFailed ? 1 : 0;
Observed output
=== (a) CONTROL: LoroTree, same local edit + undo, NO import ===
pre-edit doc.toJSON(): {"tree":[{"parent":null,"index":0,"meta":{"body":"hello"},"id":"0@1","fractional_index":"80","children":[]}]}
post-undo doc.toJSON(): {"tree":[{"parent":null,"index":0,"meta":{"body":"hello"},"id":"0@1","fractional_index":"80","children":[]}]}
post-undo tree node ids: ["0@1"]
OK undo() return value: actual=true expected=true
OK node deleted after undo: actual=false expected=false
OK text content after undo: actual="hello" expected="hello"
=== (b) DISJOINT-IMPORT: LoroTree, unrelated import lands before the edit under test ===
pre-edit doc.toJSON(): {"tree":[{"parent":null,"index":0,"meta":{"body":"hello"},"id":"0@1","fractional_index":"80","children":[]}]}
post-undo doc.toJSON(): {"tree":[{"parent":null,"index":0,"meta":{"body":"hello"},"id":"15@1","fractional_index":"80","children":[]},{"parent":null,"index":1,"meta":{"body":"unrelated content from B"},"id":"16@1","fractional_index":"8180","children":[]}]}
post-undo tree node ids: ["0@1","0@2","15@1","16@1"]
OK undo() return value: actual=true expected=true
FAIL node deleted after undo: actual=true expected=false
FAIL text content after undo: actual="<deleted>" expected="hello"
=== (c) TOP-LEVEL-MAP: LoroMap (not Tree), same import-then-edit-then-undo shape ===
pre-edit doc.toJSON(): {"map":{"body":"hello"}}
post-undo doc.toJSON(): {"tree":[{"parent":null,"index":0,"meta":{"body":"unrelated content from B"},"id":"13@1","fractional_index":"80","children":[]}],"map":{"body":"hello"}}
OK undo() return value: actual=true expected=true
OK text content after undo: actual="hello" expected="hello"
=== (d) FRESH-UNDO-MANAGER: LoroTree, UndoManager constructed AFTER the import ===
pre-edit doc.toJSON(): {"tree":[{"parent":null,"index":0,"meta":{"body":"hello"},"id":"0@1","fractional_index":"80","children":[]}]}
post-undo doc.toJSON(): {"tree":[{"parent":null,"index":0,"meta":{"body":"hello"},"id":"15@1","fractional_index":"80","children":[]},{"parent":null,"index":1,"meta":{"body":"unrelated content from B"},"id":"16@1","fractional_index":"8180","children":[]}]}
post-undo tree node ids: ["0@1","0@2","15@1","16@1"]
OK undo() return value: actual=true expected=true
FAIL node deleted after undo: actual=true expected=false
FAIL text content after undo: actual="<deleted>" expected="hello"
MATRIX: control=PASS disjoint-import=FAIL top-level-map=PASS fresh-undo-manager=FAIL
(Exit code of the process above: 1, per the script's own exit-code rule: nonzero iff any tree-leg defect assertion fails. Legs (b) and (d) each have such a failure.)
What we observed, precisely
Looking at leg (b)'s post-undo doc.toJSON() above: the original node's id (0@1) is absent from the tree, and isNodeDeleted("0@1") reports true. A different id (15@1) is present in the tree, holding the content "hello" -- which is the pre-edit baseline text, not the post-edit ("hello world") or empty state. B's unrelated node (16@1, "unrelated content from B") is also present, as expected. Leg (d) shows the identical shape with a freshly-constructed UndoManager.
We are not asserting a mechanism for this (e.g. we are not claiming the node is "reassigned" or that the operation is "corrupting" anything) -- we don't know the internal cause. We are reporting the observed public API outputs above: the original TreeID is deleted, and content matching the pre-edit state is reachable only under a different TreeID.
We also observed:
- Leg (a) (control, no import) and leg (c) (top-level
LoroMap, same import-then-edit-then-undo shape) both pass -- the original id/content is retained. This isolates the FAIL to LoroTree specifically, and to the presence of an import somewhere before the edit being undone.
- Leg (d) shows the same FAIL shape as leg (b) even though its
UndoManager was constructed strictly after the import and had tracked exactly one commit (the one under test) before undo() was called. So this does not appear to depend on the UndoManager having observed anything pre-import, or on its stack having accumulated multiple entries.
- We have not tested whether
LoroList, LoroCounter, or LoroText (outside of a tree node) behave the same as the LoroMap control above; we're reporting only what we tested.
- We have not tested whether the disjoint import is a necessary condition (only that it is sufficient, per the matrix above) -- e.g. we have not checked whether self-imports (re-importing a doc's own prior export) or purely local operations without any import ever trigger the same FAIL shape.
Additional context
We hit this via a randomized fuzz harness exercising anchor durability (Cursor/ TreeID survival) across composed edit sequences; the repro above is the minimal, isolated, independently-runnable case extracted from that finding. Happy to open a failing regression-test PR in whatever form fits your test layout. Just point me at the preferred location. I can also run any candidate fix against the randomized harness that surfaced this.
Loro's undo documentation (https://loro.dev/docs/advanced/undo, "Implementation") lists "If there is no concurrent editing, undo should return to the previous version's state" among the ensured properties. The history above is sequential (the import is not concurrent with the edit being undone), though we may be misreading the intended scope of "no concurrent editing" — which is partly why we're asking rather than asserting a contract violation.
Hi o7, I'm Steven, building Scribsy, an IDE and semantic second brain for creative writers with Loro as the document backbone. We hit this while fuzz-testing anchor durability ahead of adopting LoroTree as our manuscript spine (scenes as tree nodes). The repro below is standalone, deterministic, and depends only on the published package.
Also want to say that the Cursor API and shallow snapshots are exactly the primitives a long-manuscript editor needs, and the 1.13.x performance work was noticeable immediately, specifically the O(n²) fixes in 1.13.4 and the snapshot-import work in 1.13.5.
Environment
loro-crdt:1.13.7(npm), installed vianpm install loro-crdt@1.13.7(
resolved: https://registry.npmjs.org/loro-crdt/-/loro-crdt-1.13.7.tgz,integrity: sha512-tOvBUBSVseebXx8L5TMEdVYwgTOVZMMUtA9tQAs2vpPNRx/hClsLGhjmo9D3J4LopCVrQ6cuj3ZdBeyxQheWAg==).Our project's lockfile pins this exact version/integrity; the run below used that installed copy rather than a fresh install into an empty directory, but the resolved tarball and integrity hash are the same either way.
v22.23.16.17.0-1020-azure,x86_64LoroDoc/UndoManageronly -- no framework/bundler involved.Summary
Across four independent legs (control, disjoint-import, top-level-map, fresh-undo-manager -- all four defined in the single script below), a specific condition reproduces reliably: after a remote
import()lands,undo()-ing the next local commit that edits aLoroTreenode's data does not restore the pre-edit content under the node's originalTreeID.isNodeDeleted()reportstruefor thatTreeIDafterward, anddoc.toJSON()shows no node with that id -- the same content that was there before the edit ("hello") is instead found in the post-undo tree under a differentTreeID.This reproduces even when:
UndoManager, constructed strictly after the import (so it never observed any pre-import state), is used instead of one that pre-dates the import.A disjoint remote update imported before a subsequent local tree-node edit is sufficient to reproduce this; we have not tested whether it is necessary.
A control run with the identical local-edit-then-undo sequence but no import in between shows the original
TreeIDcorrectly retained, with matching content. A second control run against a top-levelLoroMap(not aLoroTree), with the same import-then-edit-then-undo shape, also behaves correctly. See the tested-matrix table below.Every handle used to check post-import / post-undo state in the script below (tree, node, text container) is reacquired fresh by
TreeIDafterimport()-- none are reused from before the import -- so this is not an artifact of holding a stale JavaScript wrapper object.Related issues
undo() returns false for text edits in nested tree containers after importing from independent doc #915 —
undo()returnsfalsefor text edits in nested tree containers after importing from an independent doc. Same surface as this report (UndoManager × import × LoroText nested in a LoroTree node's data map), inverted symptom: therecanUndo()istrueandundo()returnsfalse(no-op); hereundo()returnstruewhile the pre-existing node ID is deleted. undo() returns false for text edits in nested tree containers after importing from independent doc #915's repro requires an independent import source; ours reproduces with shared-history imports as well. The undo() fails silently for LoroCounter operations after the document receives changes from a remote peer #905 fix referenced there (LoroCounter / top-level LoroText, 1.10.5) matches our tested matrix — Map/List/Counter/top-level Text are unaffected for us too; only tree-nested surfaces fail — suggesting that fix family never covered tree-nested containers. Our repro persists on 1.13.7.UndoManager: redo across separate undo steps loses text when LoroTree node is recreated with new TreeID #938 — redo across separate undo steps loses text when a LoroTree node is recreated with a new TreeID (the UndoManager's
container_remapdoesn't persist across separate calls). Different trigger (redo, not undo-after-import), but the same behavior class: tree node identity is not stable across UndoManager reconstruction paths. Speculatively — offered as a pointer, not a diagnosis — remap state interacting with an import boundary in the tracked window could be a shared root cause with what we observe.Together the three reports seem to triangulate UndoManager × LoroTree node/container identity under reconstruction (import or redo) rather than being independent one-offs.
Tested matrix
LoroTree, no importLoroTree, disjoint remote import before the local edit under testLoroMap(notLoroTree), same import-then-edit-then-undo shapeLoroTree,UndoManagerconstructed strictly after the import(
PASS/FAILhere means: doesundo()restore the pre-edit content under theoriginal id, per the assertions printed in the script's own output below.)
Minimal repro
Single file, two
LoroDocreplicas,export({mode:"update"})/import(),undo()calls, plain assertions. Depends only on the published
loro-crdtpackage. Run:npm install loro-crdt@1.13.7 && npx tsx loro-undo-after-import-repro.ts.Observed output
(Exit code of the process above:
1, per the script's own exit-code rule: nonzero iff any tree-leg defect assertion fails. Legs (b) and (d) each have such a failure.)What we observed, precisely
Looking at leg (b)'s
post-undo doc.toJSON()above: the original node's id (0@1) is absent from the tree, andisNodeDeleted("0@1")reportstrue. A different id (15@1) is present in the tree, holding the content"hello"-- which is the pre-edit baseline text, not the post-edit ("hello world") or empty state. B's unrelated node (16@1,"unrelated content from B") is also present, as expected. Leg (d) shows the identical shape with a freshly-constructedUndoManager.We are not asserting a mechanism for this (e.g. we are not claiming the node is "reassigned" or that the operation is "corrupting" anything) -- we don't know the internal cause. We are reporting the observed public API outputs above: the original
TreeIDis deleted, and content matching the pre-edit state is reachable only under a differentTreeID.We also observed:
LoroMap, same import-then-edit-then-undo shape) both pass -- the original id/content is retained. This isolates the FAIL toLoroTreespecifically, and to the presence of an import somewhere before the edit being undone.UndoManagerwas constructed strictly after the import and had tracked exactly one commit (the one under test) beforeundo()was called. So this does not appear to depend on theUndoManagerhaving observed anything pre-import, or on its stack having accumulated multiple entries.LoroList,LoroCounter, orLoroText(outside of a tree node) behave the same as theLoroMapcontrol above; we're reporting only what we tested.Additional context
We hit this via a randomized fuzz harness exercising anchor durability (
Cursor/TreeIDsurvival) across composed edit sequences; the repro above is the minimal, isolated, independently-runnable case extracted from that finding. Happy to open a failing regression-test PR in whatever form fits your test layout. Just point me at the preferred location. I can also run any candidate fix against the randomized harness that surfaced this.Loro's undo documentation (https://loro.dev/docs/advanced/undo, "Implementation") lists "If there is no concurrent editing, undo should return to the previous version's state" among the ensured properties. The history above is sequential (the import is not concurrent with the edit being undone), though we may be misreading the intended scope of "no concurrent editing" — which is partly why we're asking rather than asserting a contract violation.