diff --git a/.changeset/loro-js-deleted-ancestors.md b/.changeset/loro-js-deleted-ancestors.md new file mode 100644 index 000000000..37a053c43 --- /dev/null +++ b/.changeset/loro-js-deleted-ancestors.md @@ -0,0 +1,5 @@ +--- +"loro.js": patch +--- + +`loro.js` now treats a container as deleted when any ancestor is deleted, as `loro-crdt` does: `isDeleted()`, `getPathToContainer()`, and `getCursorPos()` account for deleted ancestors. Descendants of a deleted tree node are deleted: `getNodes()` omits them, and `isNodeDeleted()`/`LoroTreeNode.isDeleted()` return `true`. `LoroTree.nodes()` returns every node, including deleted ones, and `getNodes()` lists alive nodes breadth-first from the roots, matching Rust. diff --git a/loro-js/src/runtime/containers.ts b/loro-js/src/runtime/containers.ts index d7e9c46df..e24f9ffe8 100644 --- a/loro-js/src/runtime/containers.ts +++ b/loro-js/src/runtime/containers.ts @@ -149,7 +149,7 @@ export abstract class LoroContainer { } isDeleted(): boolean { - return this._doc?._isContainerDeleted(this) ?? false; + return this._doc?._isContainerUnreachable(this) ?? false; } subscribe(listener: (event: LoroEventBatch) => void): Subscription { @@ -2916,7 +2916,20 @@ export class LoroTree< isNodeDeleted(target: TreeID): boolean { this._ensureHydrated(); - return this._nodes.get(target)?.deleted ?? false; + const record = this._nodes.get(target); + return record !== undefined && this._isNodeHidden(record); + } + + /** Whether the node or one of its ancestors is deleted. */ + _isNodeHidden(record: TreeNodeRecord): boolean { + let current: TreeNodeRecord | undefined = record; + // A parent chain longer than the node count can only be a cycle. + for (let steps = 0; steps <= this._nodes.size; steps += 1) { + if (current === undefined || current.deleted) return true; + if (current.parent === undefined) return false; + current = this._nodes.get(formatTreeId(current.parent)); + } + return true; } enableFractionalIndex(jitter = 0): void { @@ -2940,15 +2953,34 @@ export class LoroTree< return record === undefined ? undefined : new LoroTreeNode(this, record.id); } + /** + * Alive nodes in breadth-first order from the roots, like Rust. With + * `withDeleted`, the deleted nodes and their subtrees follow. + */ getNodes(options: { withDeleted?: boolean } = {}): LoroTreeNode[] { this._ensureHydrated(); - return [...this._nodes.values()] - .filter((record) => options.withDeleted === true || !record.deleted) - .map((record) => new LoroTreeNode(this, record.id)); + const records = this._subtreeRecords(this._childrenOf(undefined)); + if (options.withDeleted === true) { + const deleted = [...this._nodes.values()].filter((record) => record.deleted); + for (const record of this._subtreeRecords(deleted)) records.push(record); + } + return records.map((record) => new LoroTreeNode(this, record.id)); } + /** Every node, including deleted ones, as in Rust. */ nodes(): LoroTreeNode[] { - return this.getNodes(); + this._ensureHydrated(); + return [...this._nodes.values()].map( + (record) => new LoroTreeNode(this, record.id), + ); + } + + _subtreeRecords(starts: readonly TreeNodeRecord[]): TreeNodeRecord[] { + const output = [...starts]; + for (let index = 0; index < output.length; index += 1) { + for (const child of this._childrenOf(output[index]!.id)) output.push(child); + } + return output; } roots(): LoroTreeNode[] { @@ -3233,7 +3265,7 @@ export class LoroTreeNode = Record = Record = Record = Record { + test("treat the subtree of a deleted tree node as deleted", () => { + const doc = new LoroDoc(); + doc.setPeerId(1); + const tree = doc.getTree("tree"); + const parent = tree.createNode(); + const child = tree.createNode(parent.id); + const grandchild = tree.createNode(child.id); + const other = tree.createNode(); + doc.commit(); + tree.delete(parent.id); + doc.commit(); + + expect(tree.getNodes().map((node) => node.id)).toEqual([other.id]); + expect( + tree + .getNodes({ withDeleted: true }) + .map((node) => node.id) + .sort(), + ).toEqual([parent.id, child.id, grandchild.id, other.id].sort()); + expect( + tree + .nodes() + .map((node) => node.id) + .sort(), + ).toEqual([parent.id, child.id, grandchild.id, other.id].sort()); + expect(tree.isNodeDeleted(parent.id)).toBe(true); + expect(tree.isNodeDeleted(child.id)).toBe(true); + expect(tree.getNodeByID(grandchild.id)!.isDeleted()).toBe(true); + expect(tree.getNodeByID(other.id)!.isDeleted()).toBe(false); + expect(grandchild.data.isDeleted()).toBe(true); + expect(doc.getPathToContainer(grandchild.data.id)).toBeUndefined(); + + // Moving a node out of the deleted subtree revives it and its children. + // (loro-crdt 1.16 keeps the metadata of a node it already reported as + // deleted marked deleted after such a local move; a reload clears it.) + tree.move(child.id, undefined, 0); + doc.commit(); + expect(tree.isNodeDeleted(grandchild.id)).toBe(false); + grandchild.data.set("key", 1); + doc.commit(); + expect(tree.getNodes().map((node) => node.id)).toEqual([ + child.id, + other.id, + grandchild.id, + ]); + }); + + test("treat children of a removed map value as deleted", () => { + const doc = new LoroDoc(); + doc.setPeerId(1); + const root = doc.getMap("root"); + const map = root.setContainer("map", new LoroMap()); + const empty = map.setContainer("empty", new LoroText()); + const text = map.setContainer("text", new LoroText()); + text.insert(0, "x"); + doc.commit(); + root.delete("map"); + doc.commit(); + + for (const container of [map, empty, text]) { + expect(container.isDeleted()).toBe(true); + expect(doc.getPathToContainer(container.id)).toBeUndefined(); + expect(doc.getContainerById(container.id)).toBeDefined(); + } + }); + + test("treats a list child removed from its list as deleted", () => { + const doc = new LoroDoc(); + const list = doc.getList("list"); + const child = list.insertContainer(0, new LoroMap()); + const grandchild = child.setContainer("text", new LoroText()); + doc.commit(); + list.delete(0, 1); + doc.commit(); + expect(child.isDeleted()).toBe(true); + expect(grandchild.isDeleted()).toBe(true); + expect(doc.getPathToContainer(grandchild.id)).toBeUndefined(); + }); + + // V8 caps the arguments of one call (about 125k on Node's main thread), so + // spreading a child list into push() threw a RangeError for a node with that + // many children, or that many deleted nodes with `withDeleted`. Building + // such a tree is too slow for CI, so check the widest push() at 2,000. + test("lists nodes without passing a whole child list to one call", () => { + const doc = new LoroDoc(); + doc.setPeerId(1); + const tree = doc.getTree("tree"); + const parent = tree.createNode(); + for (let index = 0; index < 2_000; index += 1) tree.createNode(parent.id); + doc.commit(); + const widestPush = (list: () => unknown[]): number => { + const push = Array.prototype.push; + let widest = 0; + Array.prototype.push = function (this: unknown[], ...items: unknown[]) { + widest = Math.max(widest, items.length); + return push.apply(this, items); + }; + try { + list(); + } finally { + Array.prototype.push = push; + } + return widest; + }; + + expect(tree.getNodes()).toHaveLength(2_001); + expect(widestPush(() => tree.getNodes())).toBeLessThan(100); + tree.delete(parent.id); + doc.commit(); + expect(tree.getNodes({ withDeleted: true })).toHaveLength(2_001); + expect(widestPush(() => tree.getNodes({ withDeleted: true }))).toBeLessThan(100); + }); +});