From c7010b29773834c67bc39bb3c8cca1f25aeff80a Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 28 Sep 2026 01:06:18 +0800 Subject: [PATCH 01/22] fix(loro.js): include reattached child state in diff() and revertTo() A child container without ops in the range was emitted only as a reference, so applyDiff/revertTo recreated it empty. Emit its full state, as Rust does. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/loro-js-diff-reattached-child.md | 5 +++ loro-js/src/runtime/document.ts | 48 +++++++++++++++++---- loro-js/tests/runtime.test.ts | 29 +++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 .changeset/loro-js-diff-reattached-child.md diff --git a/.changeset/loro-js-diff-reattached-child.md b/.changeset/loro-js-diff-reattached-child.md new file mode 100644 index 000000000..a911f2fc7 --- /dev/null +++ b/.changeset/loro-js-diff-reattached-child.md @@ -0,0 +1,5 @@ +--- +"loro.js": patch +--- + +`diff()` and `revertTo()` in `loro.js` now include the full state of a child container the range attaches again without editing it, as Rust does. For example, reverting a map key from a string back to an older child map restored an empty map. The restored map now keeps its content, including nested containers. diff --git a/loro-js/src/runtime/document.ts b/loro-js/src/runtime/document.ts index 9b5decf69..b096a1d27 100644 --- a/loro-js/src/runtime/document.ts +++ b/loro-js/src/runtime/document.ts @@ -1126,7 +1126,7 @@ export class LoroDoc = Record { const container = this.#containers.get(id); return container === undefined ? [] : [container]; @@ -1139,14 +1139,26 @@ export class LoroDoc = Record(entries.map(([id]) => id)); + for (let index = 0; index < entries.length; index += 1) { + for (const childId of attachedChildIds(entries[index]![1])) { + if (emitted.has(childId)) continue; + emitted.add(childId); + const child = this.#containers.get(childId); + if (child === undefined) continue; + const diff = containerDiff(child, undefined); + if (!isEmptyContainerDiff(diff)) entries.push([childId, diff]); + } + } + return entries.map( + ([id, diff]) => + [id, forJson ? diffForJson(diff) : diff] as [ContainerID, Diff | JsonDiff], + ); } catch (error) { transitionFailed = useIncrementalTransition; throw error; @@ -8140,6 +8152,26 @@ function isTextEventValue(value: unknown): value is TextEventValue { ); } +/** Child containers a map or list diff attaches. */ +function attachedChildIds(diff: LoroEvent["diff"]): ContainerID[] { + const ids: ContainerID[] = []; + if (diff.type === "map") { + for (const value of Object.values(diff.updated)) { + const id = diffContainerId(value); + if (id !== undefined) ids.push(id); + } + } else if (diff.type === "list") { + for (const delta of diff.diff) { + if (!("insert" in delta)) continue; + for (const value of delta.insert) { + const id = diffContainerId(value); + if (id !== undefined) ids.push(id); + } + } + } + return ids; +} + function isEmptyContainerDiff(diff: LoroEvent["diff"]): boolean { if (diff.type === "map") return Object.keys(diff.updated).length === 0; if (diff.type === "counter") return diff.increment === 0; diff --git a/loro-js/tests/runtime.test.ts b/loro-js/tests/runtime.test.ts index eb24e33c7..e0d7b21d3 100644 --- a/loro-js/tests/runtime.test.ts +++ b/loro-js/tests/runtime.test.ts @@ -582,6 +582,35 @@ describe("loro-wasm-compatible runtime", () => { expect(doc.isDetached()).toBe(false); }); + test("reverts child containers whose content did not change in the range", () => { + const doc = new LoroDoc(); + doc.setPeerId(1); + const map = doc.getMap("map"); + const sub = map.setContainer("sub", new LoroMap()); + sub.set("k", "v"); + sub.setContainer("text", new LoroText()).insert(0, "hi"); + doc.getList("list").insertContainer(0, new LoroMap()).set("x", 1); + doc.commit(); + const populated = doc.frontiers(); + const expected = doc.toJSON(); + + // Detach both children without touching their own content. + map.set("sub", "not a map"); + doc.getList("list").delete(0, 1); + doc.commit(); + + const diff = new Map(doc.diff(doc.frontiers(), populated)); + expect(diff.get(sub.id)).toMatchObject({ type: "map", updated: { k: "v" } }); + expect(diff.get((sub.get("text") as LoroText).id)).toEqual({ + type: "text", + diff: [{ insert: "hi" }], + }); + + doc.revertTo(populated); + expect(doc.toJSON()).toEqual(expected); + expect((map.get("sub") as LoroMap).id).not.toBe(sub.id); + }); + test("undoes and redoes local commit groups", () => { const doc = new LoroDoc(); doc.setPeerId(1); From 08840f4e99323ebb44c756d94a5250c993254118 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 28 Sep 2026 02:10:14 +0800 Subject: [PATCH 02/22] fix(loro.js): emit full state only for containers that become reachable in diff() Emit a child's whole state only when it is unreachable at `from`, replace a partial diff for such a child, include revived tree node metadata, and order the result parent-first. Moved movable-list children and mergeable children keep only their own ops. applyDiff now moves an existing movable-list child instead of recreating it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/loro-js-diff-reattached-child.md | 2 +- loro-js/src/runtime/document.ts | 227 ++++++++++++++++--- loro-js/tests/diff-attached-children.test.ts | 193 ++++++++++++++++ 3 files changed, 389 insertions(+), 33 deletions(-) create mode 100644 loro-js/tests/diff-attached-children.test.ts diff --git a/.changeset/loro-js-diff-reattached-child.md b/.changeset/loro-js-diff-reattached-child.md index a911f2fc7..ce80903ed 100644 --- a/.changeset/loro-js-diff-reattached-child.md +++ b/.changeset/loro-js-diff-reattached-child.md @@ -2,4 +2,4 @@ "loro.js": patch --- -`diff()` and `revertTo()` in `loro.js` now include the full state of a child container the range attaches again without editing it, as Rust does. For example, reverting a map key from a string back to an older child map restored an empty map. The restored map now keeps its content, including nested containers. +`diff()` and `revertTo()` in `loro.js` now restore containers that the range makes reachable again: a map key set back to an older child, a revived list element, or a revived tree node's metadata. Their whole state is included, together with any nested children, in parent-before-child order, as Rust does. Containers that stay reachable, such as a moved movable-list child, and mergeable children, whose state resurfaces when the marker returns, keep only their own changes. `applyDiff` on a movable list now moves an existing child container instead of replacing it with an empty copy. diff --git a/loro-js/src/runtime/document.ts b/loro-js/src/runtime/document.ts index b096a1d27..9c8afd068 100644 --- a/loro-js/src/runtime/document.ts +++ b/loro-js/src/runtime/document.ts @@ -1141,20 +1141,56 @@ export class LoroDoc = Record(entries.map(([id]) => id)); - for (let index = 0; index < entries.length; index += 1) { - for (const childId of attachedChildIds(entries[index]![1])) { - if (emitted.has(childId)) continue; - emitted.add(childId); - const child = this.#containers.get(childId); - if (child === undefined) continue; - const diff = containerDiff(child, undefined); - if (!isEmptyContainerDiff(diff)) entries.push([childId, diff]); + // A container that is unreachable at `from` and reachable at `to` (a map + // key set back to an older child, a revived list element or tree node, + // or anything under such a container) carries its whole state, as in + // Rust; applyDiff recreates it under a new ID. A container that stays + // reachable, for example a moved movable-list element, keeps only its + // own ops. So does a mergeable child of such a container: re-ensuring + // it resurfaces its preserved state. + const diffs = new Map(entries); + const created = new Set(); + const byDepth = new Map(); + const schedule = (container: LoroContainer): void => { + const depth = containerDepth(container); + const bucket = byDepth.get(depth); + if (bucket === undefined) byDepth.set(depth, [container]); + else bucket.push(container); + }; + for (const [id] of entries) schedule(this.#containers.get(id)!); + for (let depth = 0; byDepth.size > 0; depth += 1) { + const bucket = byDepth.get(depth); + if (bucket === undefined) continue; + byDepth.delete(depth); + for (const parent of bucket) { + const diff = diffs.get(parent.id); + if (diff === undefined) continue; + const parentCreated = created.has(parent.id); + for (const child of this.#attachedChildren(parent, diff)) { + if (created.has(child.id)) continue; + if ( + !parentCreated && + (isMergeableContainerId(child._codecId!) || + this.#reachableThroughParentAt(child, parent, fromVersion)) + ) { + continue; + } + created.add(child.id); + if (!diffs.has(child.id)) schedule(child); + diffs.set(child.id, containerDiff(child, undefined)); + } } } + const ordered = [...diffs] + .filter(([, diff]) => !isEmptyContainerDiff(diff)) + .map(([id, diff]) => ({ + id, + diff, + depth: containerDepth(this.#containers.get(id)!), + })) + .sort((left, right) => left.depth - right.depth); + entries.length = 0; + for (const { id, diff } of ordered) entries.push([id, diff]); return entries.map( ([id, diff]) => [id, forJson ? diffForJson(diff) : diff] as [ContainerID, Diff | JsonDiff], @@ -1193,6 +1229,147 @@ export class LoroDoc = Record[], + containerRemap: Map, + ): boolean { + const inserted = new Set(); + for (const operation of delta) { + if (!("insert" in operation)) continue; + for (const value of operation.insert) { + const id = diffContainerId(value); + if (id !== undefined) inserted.add(id); + } + } + if (inserted.size === 0) return false; + const current = list._visibleElements(); + const moved = new Map(); + const deleted: SequenceElement[] = []; + let cursor = 0; + for (const operation of delta) { + if ("retain" in operation) { + cursor += operation.retain; + } else if ("delete" in operation) { + for (const element of current.slice(cursor, cursor + operation.delete)) { + const value = element.value; + if (value instanceof LoroContainer && inserted.has(value.id)) { + moved.set(value.id, element); + } else { + deleted.push(element); + } + } + cursor += operation.delete; + } + } + if (moved.size === 0) return false; + + const target: ({ element: SequenceElement } | { value: unknown })[] = []; + cursor = 0; + for (const operation of delta) { + if ("retain" in operation) { + for (const element of current.slice(cursor, cursor + operation.retain)) { + target.push({ element }); + } + cursor += operation.retain; + } else if ("delete" in operation) { + cursor += operation.delete; + } else { + for (const value of operation.insert) { + const id = diffContainerId(value); + const element = id === undefined ? undefined : moved.get(id); + if (element === undefined) target.push({ value }); + else { + moved.delete(id!); + target.push({ element }); + } + } + } + } + for (const element of current.slice(cursor)) target.push({ element }); + // A deleted child that the delta never re-inserts is still deleted. + deleted.push(...moved.values()); + + for (const element of deleted) { + list.delete(list._sequence.visibleIndexOf(element)!, 1); + } + for (const [index, item] of target.entries()) { + if ("element" in item) { + const from = list._sequence.visibleIndexOf(item.element)!; + if (from !== index) list.move(from, index); + continue; + } + const sourceChildId = diffContainerId(item.value); + if (sourceChildId === undefined) { + list.insert(index, item.value); + continue; + } + const parsed = parseContainerId(sourceChildId); + if (parsed.kind === "root") { + throw new TypeError("a root container cannot be inserted as a child"); + } + const child = createContainer(codecTypeToPublic(parsed.containerType)) as Container; + containerRemap.set(sourceChildId, list.insertContainer(index, child)); + } + return true; + } + + /** Child containers that a map, list, or tree diff of `parent` attaches. */ + #attachedChildren(parent: LoroContainer, diff: LoroEvent["diff"]): LoroContainer[] { + const children: LoroContainer[] = []; + const add = (id: ContainerID | undefined): void => { + const child = id === undefined ? undefined : this.#containers.get(id); + if (child !== undefined) children.push(child); + }; + if (diff.type === "map") { + for (const value of Object.values(diff.updated)) add(diffContainerId(value)); + } else if (diff.type === "list") { + for (const delta of diff.diff) { + if (!("insert" in delta)) continue; + for (const value of delta.insert) add(diffContainerId(value)); + } + } else if (diff.type === "tree" && parent instanceof LoroTree) { + for (const item of diff.diff) { + if (item.action !== "create") continue; + const record = parent._nodes.get(item.target); + if (record !== undefined) children.push(record.data); + } + } + return children; + } + + /** + * Whether `child` already occupied its slot in `parent` at `version`. Only + * a sequence can attach a child that was reachable before: a movable-list + * move reports the moved child as an insert. A map attach always replaces + * the key's value, and a tree `create` always revives a node. + */ + #reachableThroughParentAt( + child: LoroContainer, + parent: LoroContainer, + version: VersionVector, + ): boolean { + if (!(parent instanceof LoroList)) return false; + const binding = child._parentLink?.binding ?? recoverParentBinding(child, parent); + if (binding?.kind !== "sequence") return false; + const element = binding.element; + const included = (id: CodecId): boolean => id.counter < (version.get(id.peer) ?? 0); + if (!included(element.id)) return false; + if (parent._sequence.someDeletion(element, included)) return false; + const childId = child._codecId!; + if (childId.kind !== "normal") return false; + const value = latestIncludedSequenceValue(element.valueHistory, version); + const valueId = value === undefined ? element.id : value.id; + return valueId.peer === childId.peer && valueId.counter === childId.counter; + } + applyDiff(diffBatch: readonly (readonly [ContainerID, Diff | JsonDiff])[]): void { if (!Array.isArray(diffBatch)) throw new TypeError("diff batch must be an array"); if (this.#detached && !this.#detachedEditing) { @@ -1284,6 +1461,12 @@ export class LoroDoc = Record void, edit: (doc: LoroDoc) => void) { + const doc = new LoroDoc(); + doc.setPeerId(1); + build(doc); + doc.commit(); + const target = doc.frontiers(); + const expected = doc.toJSON(); + edit(doc); + doc.commit(); + const latest = doc.frontiers(); + const diff = doc.diff(latest, target); + doc.revertTo(target); + doc.commit(); + return { doc, diff, expected, target, latest }; +} + +const containerIds = (diff: [ContainerID, unknown][]): ContainerID[] => + diff.map(([id]) => id); + +describe("diff and revertTo for containers attached by the range", () => { + test("does not repeat the content of a moved movable-list child", () => { + const doc = new LoroDoc(); + doc.setPeerId(1); + const list = doc.getMovableList("l"); + const text = list.insertContainer(0, new LoroText()); + text.insert(0, "xxxx"); + list.push("tail"); + doc.commit(); + const before = doc.frontiers(); + list.move(0, 1); + doc.commit(); + + const diff = doc.diff(doc.frontiers(), before); + expect(containerIds(diff)).toEqual([list.id]); + + // Applied to a replica at the latest version, the child stays intact. + const replica = doc.fork(); + replica.setDetachedEditing(true); + replica.applyDiff(diff); + expect(replica.toJSON()).toEqual({ l: ["xxxx", "tail"] }); + + doc.revertTo(before); + expect(doc.toJSON()).toEqual({ l: ["xxxx", "tail"] }); + }); + + test("restores mergeable children once", () => { + const { doc } = revertAfter( + (doc) => { + const map = doc.getMap("m"); + map.ensureMergeableText("text").insert(0, "hello"); + map.ensureMergeableCounter("counter").increment(7); + map.ensureMergeableList("list").push("keep"); + map.ensureMergeableMovableList("movable").push("keep"); + map.ensureMergeableMap("map").set("k", "v"); + }, + (doc) => { + for (const key of ["text", "counter", "list", "movable", "map"]) { + doc.getMap("m").delete(key); + } + }, + ); + expect(doc.toJSON()).toEqual({ + m: { + text: "hello", + counter: 7, + list: ["keep"], + movable: ["keep"], + map: { k: "v" }, + }, + }); + }); + + test("restores the whole state of a reattached child that also changed", () => { + for (const parent of ["map", "list", "movable"] as const) { + const { doc, expected } = revertAfter( + (doc) => { + const child = + parent === "map" + ? doc.getMap("m").setContainer("s", new LoroMap()) + : parent === "list" + ? doc.getList("m").insertContainer(0, new LoroMap()) + : doc.getMovableList("m").insertContainer(0, new LoroMap()); + child.set("a", 1); + child.set("b", 2); + }, + (doc) => { + const container = doc.getContainerById( + parent === "map" + ? "cid:root-m:Map" + : parent === "list" + ? "cid:root-m:List" + : "cid:root-m:MovableList", + ) as LoroMap | LoroList | LoroMovableList; + const child = ( + container instanceof LoroMap ? container.get("s") : container.get(0) + ) as LoroMap; + child.set("a", 3); + if (container instanceof LoroMap) container.set("s", "replaced"); + else container.delete(0, 1); + }, + ); + expect({ parent, value: doc.toJSON() }).toEqual({ parent, value: expected }); + } + }); + + test("orders a reattached parent before its changed child", () => { + const { doc, diff, expected } = revertAfter( + (doc) => { + const s = doc.getMap("m").setContainer("s", new LoroMap()); + const g = s.setContainer("g", new LoroMap()); + g.set("a", 1); + g.set("b", 2); + s.set("keep", true); + }, + (doc) => { + const s = doc.getMap("m").get("s") as LoroMap; + (s.get("g") as LoroMap).set("a", 3); + doc.getMap("m").delete("s"); + }, + ); + expect(containerIds(diff)).toEqual(["cid:root-m:Map", "cid:0@1:Map", "cid:1@1:Map"]); + expect(doc.toJSON()).toEqual(expected); + }); + + test("restores the metadata of a revived tree node", () => { + const { doc, expected } = revertAfter( + (doc) => { + const node = doc.getTree("tree").createNode(); + node.data.set("name", "keep"); + node.data.setContainer("text", new LoroText()).insert(0, "hello"); + }, + (doc) => { + const tree = doc.getTree("tree"); + tree.delete(tree.roots()[0]!.id); + }, + ); + const [node] = (doc.toJSON() as { tree: { meta: unknown }[] }).tree; + expect(node!.meta).toEqual({ name: "keep", text: "hello" }); + expect((expected as { tree: { meta: unknown }[] }).tree[0]!.meta).toEqual(node!.meta); + }); + + test("keeps a reattached child's nested containers", () => { + const { doc, expected } = revertAfter( + (doc) => { + const s = doc.getMap("map").setContainer("sub", new LoroMap()); + s.set("k", "v"); + s.setContainer("text", new LoroText()).insert(0, "hi"); + doc.getList("list").insertContainer(0, new LoroMap()).set("x", 1); + }, + (doc) => { + doc.getMap("map").set("sub", "not a map"); + doc.getList("list").delete(0, 1); + }, + ); + expect(doc.toJSON()).toEqual(expected); + }); + + test("applies to a replica that is still at the start version", () => { + const doc = new LoroDoc(); + doc.setPeerId(1); + const map = doc.getMap("m"); + const child = map.setContainer("s", new LoroMap()); + child.set("a", 1); + doc.commit(); + const start: Frontiers = doc.frontiers(); + map.delete("s"); + doc.commit(); + const deleted = doc.frontiers(); + const diff = doc.diff(deleted, start); + const replica = doc.forkAt(deleted); + replica.setDetachedEditing(true); + replica.applyDiff(diff); + expect(replica.toJSON()).toEqual({ m: { s: { a: 1 } } }); + }); +}); From f6f28cded58e3cea0469c3f4b17bdc511a009ad8 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 28 Sep 2026 03:01:19 +0800 Subject: [PATCH 03/22] fix(loro.js): apply movable-list moves minimally, undo them, and revive tree subtrees in diff() Port Rust's MovableListHandler::apply_delta so a moved child becomes one move instead of reordering the whole list, undo movable-list moves through their recorded neighbors, and emit creates for every node that becomes reachable, including descendants of revived nodes and nodes moved out of a deleted ancestor. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/loro-js-diff-reattached-child.md | 2 +- loro-js/src/runtime/document.ts | 416 ++++++++++++++----- loro-js/tests/diff-attached-children.test.ts | 126 ++++++ 3 files changed, 443 insertions(+), 101 deletions(-) diff --git a/.changeset/loro-js-diff-reattached-child.md b/.changeset/loro-js-diff-reattached-child.md index ce80903ed..72c582996 100644 --- a/.changeset/loro-js-diff-reattached-child.md +++ b/.changeset/loro-js-diff-reattached-child.md @@ -2,4 +2,4 @@ "loro.js": patch --- -`diff()` and `revertTo()` in `loro.js` now restore containers that the range makes reachable again: a map key set back to an older child, a revived list element, or a revived tree node's metadata. Their whole state is included, together with any nested children, in parent-before-child order, as Rust does. Containers that stay reachable, such as a moved movable-list child, and mergeable children, whose state resurfaces when the marker returns, keep only their own changes. `applyDiff` on a movable list now moves an existing child container instead of replacing it with an empty copy. +`diff()` and `revertTo()` in `loro.js` now restore containers that the range makes reachable again: a map key set back to an older child, a revived list element, or a revived tree node together with its whole subtree and metadata, including a node moved out of a deleted ancestor. Their whole state is included, together with nested children, in parent-before-child order, as Rust does. Containers that stay reachable, such as a moved movable-list child, and mergeable children, whose state resurfaces when the marker returns, keep only their own changes. `applyDiff` on a movable list now turns a delete and re-insert of the same child container into a single move, as Rust's `apply_delta` does, and undo can revert movable-list moves. diff --git a/loro-js/src/runtime/document.ts b/loro-js/src/runtime/document.ts index f54a40818..0d76d5e75 100644 --- a/loro-js/src/runtime/document.ts +++ b/loro-js/src/runtime/document.ts @@ -1150,48 +1150,65 @@ export class LoroDoc = Record(entries); - const created = new Set(); - const byDepth = new Map(); - const schedule = (container: LoroContainer): void => { - const depth = containerDepth(container); - const bucket = byDepth.get(depth); - if (bucket === undefined) byDepth.set(depth, [container]); - else bucket.push(container); - }; - for (const [id] of entries) schedule(this.#containers.get(id)!); - for (let depth = 0; byDepth.size > 0; depth += 1) { - const bucket = byDepth.get(depth); - if (bucket === undefined) continue; - byDepth.delete(depth); - for (const parent of bucket) { - const diff = diffs.get(parent.id); - if (diff === undefined) continue; - const parentCreated = created.has(parent.id); - for (const child of this.#attachedChildren(parent, diff)) { - if (created.has(child.id)) continue; - if ( - !parentCreated && - (isMergeableContainerId(child._codecId!) || - this.#reachableThroughParentAt(child, parent, fromVersion)) - ) { - continue; + for (const [id, diff] of diffs) { + const tree = this.#containers.get(id); + if (diff.type === "tree" && tree instanceof LoroTree) { + diffs.set(id, { + type: "tree", + diff: this.#completeTreeRevivals(tree, diff.diff, fromVersion), + }); + } + } + // Most diffs attach no child container; skip the reachability pass and + // keep the depth order computed above. + const attachesChildren = [...diffs].some( + ([id, diff]) => + this.#attachedChildren(this.#containers.get(id)!, diff).length > 0, + ); + if (attachesChildren) { + const created = new Set(); + const byDepth = new Map(); + const schedule = (container: LoroContainer): void => { + const depth = containerDepth(container); + const bucket = byDepth.get(depth); + if (bucket === undefined) byDepth.set(depth, [container]); + else bucket.push(container); + }; + for (const [id] of entries) schedule(this.#containers.get(id)!); + for (let depth = 0; byDepth.size > 0; depth += 1) { + const bucket = byDepth.get(depth); + if (bucket === undefined) continue; + byDepth.delete(depth); + for (const parent of bucket) { + const diff = diffs.get(parent.id); + if (diff === undefined) continue; + const parentCreated = created.has(parent.id); + for (const child of this.#attachedChildren(parent, diff)) { + if (created.has(child.id)) continue; + if ( + !parentCreated && + (isMergeableContainerId(child._codecId!) || + this.#reachableThroughParentAt(child, parent, fromVersion)) + ) { + continue; + } + created.add(child.id); + if (!diffs.has(child.id)) schedule(child); + diffs.set(child.id, containerDiff(child, undefined)); } - created.add(child.id); - if (!diffs.has(child.id)) schedule(child); - diffs.set(child.id, containerDiff(child, undefined)); } } + const ordered = [...diffs] + .filter(([, diff]) => !isEmptyContainerDiff(diff)) + .map(([id, diff]) => ({ + id, + diff, + depth: containerDepth(this.#containers.get(id)!), + })) + .sort((left, right) => left.depth - right.depth); + entries.length = 0; + for (const { id, diff } of ordered) entries.push([id, diff]); } - const ordered = [...diffs] - .filter(([, diff]) => !isEmptyContainerDiff(diff)) - .map(([id, diff]) => ({ - id, - diff, - depth: containerDepth(this.#containers.get(id)!), - })) - .sort((left, right) => left.depth - right.depth); - entries.length = 0; - for (const { id, diff } of ordered) entries.push([id, diff]); return entries.map( ([id, diff]) => [id, forJson ? diffForJson(diff) : diff] as [ContainerID, Diff | JsonDiff], @@ -1232,92 +1249,234 @@ export class LoroDoc = Record[], containerRemap: Map, ): boolean { - const inserted = new Set(); - for (const operation of delta) { - if (!("insert" in operation)) continue; - for (const value of operation.insert) { - const id = diffContainerId(value); - if (id !== undefined) inserted.add(id); - } - } - if (inserted.size === 0) return false; - const current = list._visibleElements(); - const moved = new Map(); - const deleted: SequenceElement[] = []; - let cursor = 0; + // Containers in the deleted ranges, by their current index. + const toDelete = new Map(); + let index = 0; for (const operation of delta) { if ("retain" in operation) { - cursor += operation.retain; + index += operation.retain; } else if ("delete" in operation) { - for (const element of current.slice(cursor, cursor + operation.delete)) { - const value = element.value; - if (value instanceof LoroContainer && inserted.has(value.id)) { - moved.set(value.id, element); - } else { - deleted.push(element); - } + for (let offset = 0; offset < operation.delete; offset += 1) { + const value = list._sequence.atVisible(index + offset)?.value; + if (value instanceof LoroContainer) toDelete.set(value.id, index + offset); } - cursor += operation.delete; + index += operation.delete; } } - if (moved.size === 0) return false; + if (toDelete.size === 0) return false; - const target: ({ element: SequenceElement } | { value: unknown })[] = []; - cursor = 0; + const shiftOnInsert = (at: number): void => { + for (const [id, position] of toDelete) { + if (position >= at) toDelete.set(id, position + 1); + } + }; + const shiftOnDelete = (at: number): void => { + for (const [id, position] of toDelete) { + if (position >= at) toDelete.set(id, position - 1); + } + }; + index = 0; + let indexShift = 0; + const deletedIndices: number[] = []; + // Min-heap of old indices already vacated by a move; kept sorted. + const nextDeleted: number[] = []; + // Positive entries retain, negative entries delete. + const changes: number[] = []; for (const operation of delta) { if ("retain" in operation) { - for (const element of current.slice(cursor, cursor + operation.retain)) { - target.push({ element }); + index += operation.retain; + changes.push(operation.retain); + continue; + } + if ("delete" in operation) { + const oldIndex = index; + let remaining = operation.delete; + while ( + nextDeleted.length > 0 && + nextDeleted[0]! + indexShift < index + remaining + ) { + nextDeleted.shift(); + remaining -= 1; } - cursor += operation.retain; - } else if ("delete" in operation) { - cursor += operation.delete; - } else { - for (const value of operation.insert) { - const id = diffContainerId(value); - const element = id === undefined ? undefined : moved.get(id); - if (element === undefined) target.push({ value }); - else { - moved.delete(id!); - target.push({ element }); + index += remaining; + changes.push(-(index - oldIndex)); + continue; + } + for (const value of operation.insert) { + let sourceId = diffContainerId(value); + if (sourceId === undefined) { + list.insert(index, value); + shiftOnInsert(index); + index += 1; + indexShift += 1; + continue; + } + if (!toDelete.has(sourceId)) { + for (let mapped = containerRemap.get(sourceId); mapped !== undefined; ) { + sourceId = mapped.id; + if (toDelete.has(sourceId)) break; + mapped = containerRemap.get(sourceId); + } + } + const oldIndex = toDelete.get(sourceId); + if (oldIndex !== undefined) { + toDelete.delete(sourceId); + if (oldIndex > index) { + list.move(oldIndex, index); + const at = sortedInsertIndex(nextDeleted, oldIndex); + nextDeleted.splice(at, 0, oldIndex); + index += 1; + indexShift += 1; + } else { + list.move(oldIndex, index - 1); } + deletedIndices.push(oldIndex); + shiftOnDelete(oldIndex); + shiftOnInsert(index); + continue; + } + const parsed = parseContainerId(sourceId); + if (parsed.kind === "root") { + throw new TypeError("a root container cannot be inserted as a child"); + } + const child = createContainer( + codecTypeToPublic(parsed.containerType), + ) as Container; + containerRemap.set(sourceId, list.insertContainer(index, child)); + shiftOnInsert(index); + index += 1; + indexShift += 1; + } + changes.push(operation.insert.length); + } + + // Apply the remaining deletions, skipping slots vacated by moves. + deletedIndices.sort((left, right) => right - left); + index = 0; + for (const change of changes) { + if (change > 0) { + index += change; + continue; + } + let remaining = -change; + while (deletedIndices.length > 0) { + const last = deletedIndices.at(-1)!; + if (last < index) { + deletedIndices.pop(); + continue; + } + if (last < index + remaining) { + deletedIndices.pop(); + remaining -= 1; + } else { + break; } } + if (remaining > 0) list.delete(index, remaining); } - for (const element of current.slice(cursor)) target.push({ element }); - // A deleted child that the delta never re-inserts is still deleted. - deleted.push(...moved.values()); + return true; + } - for (const element of deleted) { - list.delete(list._sequence.visibleIndexOf(element)!, 1); - } - for (const [index, item] of target.entries()) { - if ("element" in item) { - const from = list._sequence.visibleIndexOf(item.element)!; - if (from !== index) list.move(from, index); + /** + * A tree node that is alive at `to` but not at `from` (it or an ancestor + * was deleted) is created again together with its whole subtree, parent + * first, as Rust reports it. A recorded tree diff only lists the nodes whose + * own records changed: reviving a parent omits its children, and moving a + * node out of a deleted ancestor shows up as a plain move. + */ + #completeTreeRevivals( + tree: LoroTree, + items: readonly TreeDiffItem[], + from: VersionVector, + ): TreeDiffItem[] { + const revived = new Set(); + for (const item of items) { + if (item.action === "delete") continue; + const record = tree._nodes.get(item.target); + if (record === undefined || this.#isTreeRecordHidden(tree, record)) continue; + if (item.action === "create" || !this.#treeNodeAliveAt(tree, record.id, from)) { + revived.add(item.target); + } + } + if (revived.size === 0) return [...items]; + const insideRevived = (target: TreeID): boolean => { + let parent = tree._nodes.get(target)?.parent; + while (parent !== undefined) { + const key = formatTreeId(parent); + if (revived.has(key)) return true; + parent = tree._nodes.get(key)?.parent; + } + return false; + }; + const output: TreeDiffItem[] = []; + for (const item of items) { + if (item.action === "delete" || !revived.has(item.target)) { + if (item.action === "delete" || !insideRevived(item.target)) output.push(item); continue; } - const sourceChildId = diffContainerId(item.value); - if (sourceChildId === undefined) { - list.insert(index, item.value); - continue; + if (insideRevived(item.target)) continue; + const pending = [tree._nodes.get(item.target)!]; + while (pending.length > 0) { + const record = pending.shift()!; + const node = this.#treeEventNode(tree, record); + output.push({ + target: node.id, + action: "create", + parent: node.parent, + index: node.index, + fractionalIndex: node.fractionalIndex, + }); + pending.unshift(...tree._childrenOf(record.id)); } - const parsed = parseContainerId(sourceChildId); - if (parsed.kind === "root") { - throw new TypeError("a root container cannot be inserted as a child"); + } + return output; + } + + #isTreeRecordHidden(tree: LoroTree, record: TreeNodeRecord): boolean { + for (let current: TreeNodeRecord | undefined = record; current !== undefined; ) { + if (current.deleted) return true; + current = + current.parent === undefined + ? undefined + : tree._nodes.get(formatTreeId(current.parent)); + } + return false; + } + + /** Whether `node` and all of its ancestors are alive at `version`. */ + #treeNodeAliveAt(tree: LoroTree, node: CodecId, version: VersionVector): boolean { + const history = this.#treeOperationHistory.get(tree.id); + const visited = new Set(); + for (let current: CodecId | undefined = node; current !== undefined; ) { + const key = idKey(current); + if (visited.has(key)) return false; + visited.add(key); + const operations = history?.get(key); + if (operations === undefined) { + // No retained op (e.g. a shallow root node): its placement is unchanged. + const record = tree._nodes.get(formatTreeId(current)); + if (record === undefined || record.deleted) return false; + current = record.parent; + continue; } - const child = createContainer(codecTypeToPublic(parsed.containerType)) as Container; - containerRemap.set(sourceChildId, list.insertContainer(index, child)); + const winner = latestIncludedOperation(operations, version); + if (winner === undefined) return false; + const content = winner.operation.content; + if (content.type !== "tree-create" && content.type !== "tree-move") return false; + current = content.parent; } return true; } @@ -2352,14 +2511,60 @@ export class LoroDoc = Record, + peer: bigint, + counter: number, + ): void { + const element = list._sequence.findByLamport( + content.elementId.peer, + content.elementId.lamport, + ); + if (element === undefined || element.deleted) return; + const meta = element.moveHistory?.at(-1); + if (meta === undefined || meta.id.peer !== peer || meta.id.counter !== counter) + return; + const from = list._sequence.visibleIndexOf(element)!; + const visibleIndex = (id: CodecId | undefined): number | undefined => { + if (id === undefined) return undefined; + const neighbor = list._sequence.findById(id); + return neighbor === undefined || neighbor.deleted + ? undefined + : list._sequence.visibleIndexOf(neighbor); + }; + let to: number; + const previous = visibleIndex(meta.beforePrevious); + const next = visibleIndex(meta.beforeNext); + if (previous !== undefined) to = previous < from ? previous + 1 : previous; + else if (next !== undefined) to = next < from ? next : next - 1; + else if (meta.beforePrevious === undefined) to = 0; + else if (meta.beforeNext === undefined) to = list.length - 1; + else return; + if (to !== from) list.move(from, to); + } + #deleteInsertedElements( container: LoroText | LoroList, peer: bigint, @@ -8384,6 +8589,17 @@ function isTextEventValue(value: unknown): value is TextEventValue { ); } +function sortedInsertIndex(values: readonly number[], value: number): number { + let low = 0; + let high = values.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (values[middle]! < value) low = middle + 1; + else high = middle; + } + return low; +} + function isEmptyContainerDiff(diff: LoroEvent["diff"]): boolean { if (diff.type === "map") return Object.keys(diff.updated).length === 0; if (diff.type === "counter") return diff.increment === 0; diff --git a/loro-js/tests/diff-attached-children.test.ts b/loro-js/tests/diff-attached-children.test.ts index 12a95c576..b27043ea9 100644 --- a/loro-js/tests/diff-attached-children.test.ts +++ b/loro-js/tests/diff-attached-children.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { + UndoManager, LoroDoc, LoroList, LoroMap, @@ -190,4 +191,129 @@ describe("diff and revertTo for containers attached by the range", () => { replica.applyDiff(diff); expect(replica.toJSON()).toEqual({ m: { s: { a: 1 } } }); }); + + test("applyDiff moves an existing child with one undoable move", () => { + for (const toEnd of [true, false]) { + const doc = new LoroDoc(); + doc.setPeerId(1); + const list = doc.getMovableList("l"); + const text = list.insertContainer(0, new LoroText()); + text.insert(0, "xxxx"); + for (let index = 0; index < 5; index += 1) list.push(index); + if (!toEnd) list.move(0, 5); + doc.commit(); + const before = doc.toJSON(); + const opsBefore = doc.opCount(); + const undo = new UndoManager(doc, { mergeInterval: 0 }); + doc.applyDiff([ + [ + list.id, + { + type: "list", + diff: toEnd + ? [{ delete: 1 }, { retain: 5 }, { insert: [`๐Ÿฆœ:${text.id}`] }] + : [{ insert: [`๐Ÿฆœ:${text.id}`] }, { retain: 5 }, { delete: 1 }], + }, + ], + ]); + doc.commit(); + const after = doc.toJSON(); + expect(after).toEqual({ + l: toEnd ? [0, 1, 2, 3, 4, "xxxx"] : ["xxxx", 0, 1, 2, 3, 4], + }); + // Rust's MovableList apply_delta emits one move for the pair. + expect(doc.opCount() - opsBefore).toBe(1); + expect(undo.undo()).toBe(true); + expect(doc.toJSON()).toEqual(before); + expect(undo.redo()).toBe(true); + expect(doc.toJSON()).toEqual(after); + } + }); + + test("an applied move merges with a concurrent move like Rust's", () => { + const base = new LoroDoc(); + base.setPeerId(1); + const list = base.getMovableList("l"); + const text = list.insertContainer(0, new LoroText()); + text.insert(0, "text"); + for (const value of ["A", "B", "C"]) list.push(value); + base.commit(); + const version = base.oplogVersion(); + + const mover = base.fork(); + mover.setPeerId(2); + mover.applyDiff([ + [ + list.id, + { + type: "list", + diff: [{ delete: 1 }, { retain: 3 }, { insert: [`๐Ÿฆœ:${text.id}`] }], + }, + ], + ]); + mover.commit(); + const concurrent = base.fork(); + concurrent.setPeerId(3); + concurrent.getMovableList("l").move(3, 0); + concurrent.commit(); + + const receiver = base.fork(); + receiver.import(mover.export({ mode: "update", from: version })); + receiver.import(concurrent.export({ mode: "update", from: version })); + expect(receiver.toJSON()).toEqual({ l: ["C", "A", "B", "text"] }); + }); + + test("restores a revived parent's whole subtree", () => { + const { doc, diff } = revertAfter( + (doc) => { + const tree = doc.getTree("tree"); + const parent = tree.createNode(); + const child = parent.createNode(); + child.data.set("k", "v"); + child.createNode().data.setContainer("t", new LoroText()).insert(0, "deep"); + }, + (doc) => doc.getTree("tree").delete("0@1"), + ); + expect( + diff + .filter(([id]) => id === "cid:root-tree:Tree") + .flatMap(([, value]) => (value as { diff: { action: string }[] }).diff) + .map((item) => item.action), + ).toEqual(["create", "create", "create"]); + const [root] = (doc.toJSON() as { tree: TreeJson[] }).tree; + expect(root!.children[0]!.meta).toEqual({ k: "v" }); + expect(root!.children[0]!.children[0]!.meta).toEqual({ t: "deep" }); + }); + + test("recreates a node moved out of a deleted ancestor", () => { + const doc = new LoroDoc(); + doc.setPeerId(1); + const tree = doc.getTree("tree"); + const parent = tree.createNode(); + const child = parent.createNode(); + child.data.setContainer("text", new LoroText()).insert(0, "hello"); + child.data.ensureMergeableText("merge").insert(0, "keep"); + doc.commit(); + tree.delete(parent.id); + doc.commit(); + const from = doc.frontiers(); + tree.move(child.id); + doc.commit(); + const diff = doc.diff(from, doc.frontiers()); + const treeItems = diff.find(([id]) => id === tree.id)![1] as { + diff: { action: string }[]; + }; + expect(treeItems.diff.map((item) => item.action)).toEqual(["create"]); + + const replica = doc.forkAt(from); + replica.setDetachedEditing(true); + replica.applyDiff(diff); + const [node] = (replica.toJSON() as { tree: TreeJson[] }).tree; + expect(node!.meta).toEqual({ text: "hello", merge: "keep" }); + }); }); + +interface TreeJson { + readonly meta: unknown; + readonly children: TreeJson[]; +} From 784f8e7a9c63862ca2556bf442a4d186ec8c2c75 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 28 Sep 2026 03:10:58 +0800 Subject: [PATCH 04/22] fix(loro.js): read a trimmed tree node's placement from the shallow root in diff() On a shallow doc a node created before the root has no retained op at the root version; use its root-state placement instead of treating it as revived. Co-Authored-By: Claude Opus 5.5 (1M context) --- loro-js/src/runtime/document.ts | 55 +++++++++++++++++++- loro-js/tests/diff-attached-children.test.ts | 26 +++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/loro-js/src/runtime/document.ts b/loro-js/src/runtime/document.ts index 0d76d5e75..3891e6b7d 100644 --- a/loro-js/src/runtime/document.ts +++ b/loro-js/src/runtime/document.ts @@ -286,6 +286,11 @@ export class LoroDoc = Record> + >(); #textStyles = new Map([ ["bold", "after"], ["italic", "after"], @@ -1456,6 +1461,47 @@ export class LoroDoc = Record this.#containerKey(entry.id) === treeKey, + )?.wrapper.state; + if (state?.kind === CodecContainerType.Tree) { + const ids = state.nodes.map((node) => ({ + peer: state.peers[Number(node.peerIndex)]!, + counter: node.counter, + })); + for (const [index, node] of state.nodes.entries()) { + nodes.set(idKey(ids[index]!), { + deleted: node.parentIndexPlusTwo === 1n, + parent: + node.parentIndexPlusTwo >= 2n + ? ids[Number(node.parentIndexPlusTwo - 2n)] + : undefined, + }); + } + } + byTree.set(treeKey, nodes); + } + return nodes.get(nodeKey); + } + /** Whether `node` and all of its ancestors are alive at `version`. */ #treeNodeAliveAt(tree: LoroTree, node: CodecId, version: VersionVector): boolean { const history = this.#treeOperationHistory.get(tree.id); @@ -1473,7 +1519,14 @@ export class LoroDoc = Record { expect(root!.children[0]!.children[0]!.meta).toEqual({ t: "deep" }); }); + // With a shallow history, the same move must stay a move even though the + // create op was trimmed (#treeNodeAliveAt falls back to the root state). The + // shallow retreat that needs is fixed separately (loro-dev/loro#1127), whose + // shallow-root-map-winner tests cover the combination. + test("reports a tree move of a node that stays alive as a move", () => { + const doc = new LoroDoc(); + doc.setPeerId(1); + const tree = doc.getTree("tree"); + const moved = tree.createNode(); + const target = tree.createNode(); + moved.data.set("x", "root"); + doc.commit(); + const root = doc.frontiers(); + tree.move(moved.id, target.id); + doc.commit(); + const diff = doc.diff(root, doc.frontiers()); + const items = diff.find(([id]) => id === tree.id)![1] as { + diff: { action: string }[]; + }; + expect(items.diff.map((item) => item.action)).toEqual(["move"]); + const replica = doc.forkAt(root); + replica.setDetachedEditing(true); + replica.applyDiff(diff); + expect(replica.toJSON()).toEqual(doc.toJSON()); + }); + test("recreates a node moved out of a deleted ancestor", () => { const doc = new LoroDoc(); doc.setPeerId(1); From e7415e21253af1ffe1937c0a240e7bd9e5d433c6 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 28 Sep 2026 04:05:08 +0800 Subject: [PATCH 05/22] fix(loro.js): resolve movable-list moves by element identity, undo repeated moves, and restore mergeable markers on undo applyDiff now finds moved children through their inserted ids and places every inserted item after its final-order predecessor, so unmerged delete items, several children moved out of one range, and scalar-only deletes are handled without index bookkeeping or scans. Undo reverts earlier moves of an element that only this document moved since. A wide revived subtree is walked iteratively. Undoing a mergeable key deletion re-ensures the marker. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/loro-js-diff-reattached-child.md | 2 +- loro-js/src/runtime/document.ts | 314 +++++++++++-------- loro-js/tests/diff-attached-children.test.ts | 170 ++++++++++ 3 files changed, 356 insertions(+), 130 deletions(-) diff --git a/.changeset/loro-js-diff-reattached-child.md b/.changeset/loro-js-diff-reattached-child.md index 72c582996..45e7a992c 100644 --- a/.changeset/loro-js-diff-reattached-child.md +++ b/.changeset/loro-js-diff-reattached-child.md @@ -2,4 +2,4 @@ "loro.js": patch --- -`diff()` and `revertTo()` in `loro.js` now restore containers that the range makes reachable again: a map key set back to an older child, a revived list element, or a revived tree node together with its whole subtree and metadata, including a node moved out of a deleted ancestor. Their whole state is included, together with nested children, in parent-before-child order, as Rust does. Containers that stay reachable, such as a moved movable-list child, and mergeable children, whose state resurfaces when the marker returns, keep only their own changes. `applyDiff` on a movable list now turns a delete and re-insert of the same child container into a single move, as Rust's `apply_delta` does, and undo can revert movable-list moves. +`diff()` and `revertTo()` in `loro.js` now restore containers that the range makes reachable again: a map key set back to an older child, a revived list element, or a revived tree node with its whole subtree and metadata, including a node moved out of a deleted ancestor. Their whole state is included, together with nested children, in parent-before-child order, as Rust does. Containers that stay reachable, such as a moved movable-list child, keep only their own changes. So does a mergeable child re-activated inside a container that keeps its id: its delta is relative to its hidden state. `applyDiff` on a movable list moves an existing child container instead of creating an empty copy, and handles unmerged delete items and several children moved out of one deleted range. Undo can revert movable-list moves, including several moves of the same element. Undoing the deletion of a mergeable key restores its marker, so the child keeps its identity and edits made while it was hidden. diff --git a/loro-js/src/runtime/document.ts b/loro-js/src/runtime/document.ts index 2912a8079..dd08830d8 100644 --- a/loro-js/src/runtime/document.ts +++ b/loro-js/src/runtime/document.ts @@ -1254,143 +1254,172 @@ export class LoroDoc = Record[], containerRemap: Map, ): boolean { - // Containers in the deleted ranges, by their current index. - const toDelete = new Map(); + // Deleted ranges in original (pre-delta) indices, in ascending order. + const deletedRanges: { readonly start: number; readonly length: number }[] = []; + const insertedChildren: ContainerID[] = []; let index = 0; for (const operation of delta) { if ("retain" in operation) { index += operation.retain; } else if ("delete" in operation) { - for (let offset = 0; offset < operation.delete; offset += 1) { - const value = list._sequence.atVisible(index + offset)?.value; - if (value instanceof LoroContainer) toDelete.set(value.id, index + offset); + const last = deletedRanges.at(-1); + if (last !== undefined && last.start + last.length === index) { + deletedRanges[deletedRanges.length - 1] = { + start: last.start, + length: last.length + operation.delete, + }; + } else if (operation.delete > 0) { + deletedRanges.push({ start: index, length: operation.delete }); } index += operation.delete; + } else { + for (const value of operation.insert) { + const id = diffContainerId(value); + if (id !== undefined) insertedChildren.push(id); + } } } - if (toDelete.size === 0) return false; + if (insertedChildren.length === 0 || deletedRanges.length === 0) return false; - const shiftOnInsert = (at: number): void => { - for (const [id, position] of toDelete) { - if (position >= at) toDelete.set(id, position + 1); + const inDeletedRange = (position: number): boolean => { + let low = 0; + let high = deletedRanges.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (deletedRanges[middle]!.start <= position) low = middle + 1; + else high = middle; } + const range = deletedRanges[low - 1]; + return range !== undefined && position < range.start + range.length; }; - const shiftOnDelete = (at: number): void => { - for (const [id, position] of toDelete) { - if (position >= at) toDelete.set(id, position - 1); - } + // Inserted children that currently occupy a deleted slot of this list. + const moved = new Map(); + for (const sourceId of insertedChildren) { + let id: ContainerID | undefined = sourceId; + let child = this.#containers.get(id); + for (let hops = 0; child === undefined && hops < 64; hops += 1) { + id = containerRemap.get(id!)?.id; + if (id === undefined) break; + child = this.#containers.get(id); + } + if (child === undefined || child.parent() !== list) continue; + const binding = child._parentLink?.binding ?? recoverParentBinding(child, list); + if (binding?.kind !== "sequence" || binding.element.value !== child) continue; + if (binding.element.deleted) continue; + const position = list._sequence.visibleIndexOf(binding.element); + if (position === undefined || !inDeletedRange(position)) continue; + if (!moved.has(sourceId)) + moved.set(sourceId, { element: binding.element, index: position }); + } + if (moved.size === 0) return false; + + // Walk the delta once more: each inserted item is placed right after its + // predecessor in the final order (a retained element or an earlier item). + type Item = { + readonly value: unknown; + readonly movedElement: SequenceElement | undefined; + readonly after: Predecessor | undefined; + placed: SequenceElement | undefined; }; + type Predecessor = + | { readonly kind: "retained"; readonly element: SequenceElement } + | { readonly kind: "item"; readonly item: Item }; + const items: Item[] = []; + const used = new Set(); + const movedElements = new Set(); + let predecessor: Predecessor | undefined; index = 0; - let indexShift = 0; - const deletedIndices: number[] = []; - // Min-heap of old indices already vacated by a move; kept sorted. - const nextDeleted: number[] = []; - // Positive entries retain, negative entries delete. - const changes: number[] = []; for (const operation of delta) { if ("retain" in operation) { - index += operation.retain; - changes.push(operation.retain); - continue; - } - if ("delete" in operation) { - const oldIndex = index; - let remaining = operation.delete; - while ( - nextDeleted.length > 0 && - nextDeleted[0]! + indexShift < index + remaining - ) { - nextDeleted.shift(); - remaining -= 1; - } - index += remaining; - changes.push(-(index - oldIndex)); - continue; - } - for (const value of operation.insert) { - let sourceId = diffContainerId(value); - if (sourceId === undefined) { - list.insert(index, value); - shiftOnInsert(index); - index += 1; - indexShift += 1; - continue; - } - if (!toDelete.has(sourceId)) { - for (let mapped = containerRemap.get(sourceId); mapped !== undefined; ) { - sourceId = mapped.id; - if (toDelete.has(sourceId)) break; - mapped = containerRemap.get(sourceId); - } + if (operation.retain > 0) { + index += operation.retain; + predecessor = { + kind: "retained", + element: list._sequence.atVisible(index - 1)!, + }; } - const oldIndex = toDelete.get(sourceId); - if (oldIndex !== undefined) { - toDelete.delete(sourceId); - if (oldIndex > index) { - list.move(oldIndex, index); - const at = sortedInsertIndex(nextDeleted, oldIndex); - nextDeleted.splice(at, 0, oldIndex); - index += 1; - indexShift += 1; - } else { - list.move(oldIndex, index - 1); + } else if ("delete" in operation) { + index += operation.delete; + } else { + for (const value of operation.insert) { + const id = diffContainerId(value); + const entry = id === undefined || used.has(id) ? undefined : moved.get(id); + if (entry !== undefined) { + used.add(id!); + movedElements.add(entry.element); } - deletedIndices.push(oldIndex); - shiftOnDelete(oldIndex); - shiftOnInsert(index); - continue; - } - const parsed = parseContainerId(sourceId); - if (parsed.kind === "root") { - throw new TypeError("a root container cannot be inserted as a child"); + const item: Item = { + value, + movedElement: entry?.element, + after: predecessor, + placed: undefined, + }; + items.push(item); + predecessor = { kind: "item", item }; } - const child = createContainer( - codecTypeToPublic(parsed.containerType), - ) as Container; - containerRemap.set(sourceId, list.insertContainer(index, child)); - shiftOnInsert(index); - index += 1; - indexShift += 1; } - changes.push(operation.insert.length); } - // Apply the remaining deletions, skipping slots vacated by moves. - deletedIndices.sort((left, right) => right - left); - index = 0; - for (const change of changes) { - if (change > 0) { - index += change; + // Delete the deleted ranges back to front, keeping the moved elements. + const kept = new Set( + [...moved.values()] + .filter(({ element }) => movedElements.has(element)) + .map(({ index: position }) => position), + ); + for (let rangeIndex = deletedRanges.length - 1; rangeIndex >= 0; rangeIndex -= 1) { + const range = deletedRanges[rangeIndex]!; + let end = range.start + range.length; + for (let position = end - 1; position >= range.start - 1; position -= 1) { + if (position >= range.start && !kept.has(position)) continue; + const start = position + 1; + if (end > start) list.delete(start, end - start); + end = position; + } + } + + for (const item of items) { + const after = + item.after === undefined + ? undefined + : item.after.kind === "retained" + ? item.after.element + : item.after.item.placed; + let target = after === undefined ? 0 : list._sequence.visibleIndexOf(after)! + 1; + if (item.movedElement !== undefined) { + const from = list._sequence.visibleIndexOf(item.movedElement)!; + if (from < target) target -= 1; + if (from !== target) list.move(from, target); + item.placed = item.movedElement; continue; } - let remaining = -change; - while (deletedIndices.length > 0) { - const last = deletedIndices.at(-1)!; - if (last < index) { - deletedIndices.pop(); - continue; - } - if (last < index + remaining) { - deletedIndices.pop(); - remaining -= 1; - } else { - break; + const sourceChildId = diffContainerId(item.value); + if (sourceChildId === undefined) { + list.insert(target, item.value); + } else { + const parsed = parseContainerId(sourceChildId); + if (parsed.kind === "root") { + throw new TypeError("a root container cannot be inserted as a child"); } + const child = createContainer( + codecTypeToPublic(parsed.containerType), + ) as Container; + containerRemap.set(sourceChildId, list.insertContainer(target, child)); } - if (remaining > 0) list.delete(index, remaining); + item.placed = list._sequence.atVisible(target); } return true; } @@ -1433,18 +1462,31 @@ export class LoroDoc = Record 0) { - const record = pending.shift()!; - const node = this.#treeEventNode(tree, record); + // Iterative preorder: a wide subtree must not be spread into call + // arguments, and each node is visited once. + const emit = (record: TreeNodeRecord, index: number): void => { output.push({ - target: node.id, + target: formatTreeId(record.id), action: "create", - parent: node.parent, - index: node.index, - fractionalIndex: node.fractionalIndex, + parent: record.parent === undefined ? undefined : formatTreeId(record.parent), + index, + fractionalIndex: bytesToHex(record.position).toUpperCase(), }); - pending.unshift(...tree._childrenOf(record.id)); + }; + const top = tree._nodes.get(item.target)!; + emit(top, tree._indexOf(top)); + const stack = [{ children: tree._childrenOf(top.id), next: 0 }]; + while (stack.length > 0) { + const frame = stack.at(-1)!; + if (frame.next === frame.children.length) { + stack.pop(); + continue; + } + const index = frame.next; + frame.next += 1; + const record = frame.children[index]!; + emit(record, index); + stack.push({ children: tree._childrenOf(record.id), next: 0 }); } } return output; @@ -2583,7 +2625,7 @@ export class LoroDoc = Record = Record= 0 && + (history[metaIndex]!.id.peer !== peer || history[metaIndex]!.id.counter !== counter) + ) { + metaIndex -= 1; + } + if (metaIndex < 0) return; + // Later moves by this document (e.g. the inverse ops written while undoing + // a later move in the same group) do not block undoing this one; a later + // remote move does. + for (let later = metaIndex + 1; later < history.length; later += 1) { + if (history[later]!.id.peer !== this.#peer) return; + } + const meta = history[metaIndex]!; const from = list._sequence.visibleIndexOf(element)!; const visibleIndex = (id: CodecId | undefined): number | undefined => { if (id === undefined) return undefined; @@ -2743,6 +2798,18 @@ export class LoroDoc = Record