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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/loro-js-deleted-ancestors.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 39 additions & 7 deletions loro-js/src/runtime/containers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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<T>[] {
this._ensureHydrated();
return [...this._nodes.values()]
.filter((record) => options.withDeleted === true || !record.deleted)
.map((record) => new LoroTreeNode<T>(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<T>(this, record.id));
}

/** Every node, including deleted ones, as in Rust. */
nodes(): LoroTreeNode<T>[] {
return this.getNodes();
this._ensureHydrated();
return [...this._nodes.values()].map(
(record) => new LoroTreeNode<T>(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<T>[] {
Expand Down Expand Up @@ -3233,7 +3265,7 @@ export class LoroTreeNode<T extends Record<string, unknown> = Record<string, unk
}

isDeleted(): boolean {
return this.#record().deleted;
return this.#tree._isNodeHidden(this.#record());
}

getLastMoveId(): { peer: string; counter: number } {
Expand Down
32 changes: 30 additions & 2 deletions loro-js/src/runtime/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3253,7 +3253,8 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai

getPathToContainer(id: ContainerID): Path | undefined {
const container = this.getContainerById(id);
if (container === undefined || this._isContainerDeleted(container)) return undefined;
if (container === undefined || this._isContainerUnreachable(container))
return undefined;
return containerPath(container);
}

Expand Down Expand Up @@ -3287,7 +3288,8 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
cursor: Cursor,
): { update?: Cursor; offset: number; side: Side } | undefined {
const container = this.getContainerById(cursor.containerId());
if (container === undefined || this._isContainerDeleted(container)) return undefined;
if (container === undefined || this._isContainerUnreachable(container))
return undefined;
const id = cursor._idValue();
if (!(container instanceof LoroList || container instanceof LoroText))
return undefined;
Expand Down Expand Up @@ -3398,6 +3400,32 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
};
}

/**
* Whether `container` is unreachable from the roots: it or an ancestor was
* removed from its parent, or its tree node is under a deleted node, as in
* Rust's `DocState::is_deleted`. `_isContainerDeleted` checks only the
* container's own slot.
*/
_isContainerUnreachable(container: LoroContainer): boolean {
for (
let current: LoroContainer | undefined = container;
current !== undefined;
current = current.parent()
) {
if (this._isContainerDeleted(current)) return true;
const binding = current._parentLink?.binding;
const parent = current.parent();
if (
binding?.kind === "tree" &&
parent instanceof LoroTree &&
parent._isNodeHidden(binding.record)
) {
return true;
}
}
return false;
}

_isContainerDeleted(container: LoroContainer): boolean {
if (
container._codecId?.kind === "root" &&
Expand Down
123 changes: 123 additions & 0 deletions loro-js/tests/deleted-containers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, expect, test } from "vitest";

import { LoroDoc, LoroMap, LoroText } from "../src/index";

// A container is deleted when any ancestor was removed from its parent, and a
// tree node is deleted when any ancestor node is. Expected values are what
// loro-crdt returns for the same calls. (loro-crdt also rejects local edits to
// a deleted container; loro.js still accepts them.)

describe("deleted containers and tree nodes", () => {
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);
});
});
Loading