Skip to content

Commit 8aa5c23

Browse files
Fix unhydrated node parent tracking (#24708)
## Description Fix unhydrated node parent tracking by centralizing the logic to update parents, and applying it consistently after every edit. This does make small edits in large sequences do O(N) parent updates, but they were already O(N) from modifying the large array, and need to do O(N) parent updates in the insertion is near the front anyway.
1 parent 4208103 commit 8aa5c23

File tree

3 files changed

+91
-69
lines changed

3 files changed

+91
-69
lines changed

.changeset/eight-ears-feel.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@fluidframework/tree": minor
3+
"fluid-framework": minor
4+
"__section": tree
5+
---
6+
Fix Tree.key and Tree.parent for Unhydrated nodes after edits
7+
8+
In some cases, editing [Unhydrated](https://fluidframework.com/docs/api/fluid-framework/unhydrated-typealias) nodes could result in incorrect results being returned from [Tree.key](https://fluidframework.com/docs/data-structures/tree/nodes#treekey) and [Tree.parent](https://fluidframework.com/docs/data-structures/tree/nodes#treeparent).
9+
This has been fixed.

packages/dds/tree/src/simple-tree/core/unhydratedFlexTree.ts

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -402,13 +402,24 @@ class UnhydratedFlexTreeField implements FlexTreeField {
402402
*/
403403
protected edit(edit: (mapTrees: ExclusiveMapTree[]) => void | ExclusiveMapTree[]): void {
404404
const oldMapTrees = this.parent.mapTree.fields.get(this.key) ?? [];
405+
406+
// Clear parents for all old map trees.
407+
for (const tree of oldMapTrees) {
408+
tryUnhydratedFlexTreeNode(tree)?.adoptBy(undefined);
409+
}
410+
405411
const newMapTrees = edit(oldMapTrees) ?? oldMapTrees;
406412
if (newMapTrees.length > 0) {
407413
this.parent.mapTree.fields.set(this.key, newMapTrees);
408414
} else {
409415
this.parent.mapTree.fields.delete(this.key);
410416
}
411417

418+
// Set parents for all new map trees.
419+
for (const [index, tree] of newMapTrees.entries()) {
420+
tryUnhydratedFlexTreeNode(tree)?.adoptBy(this, index);
421+
}
422+
412423
this.onEdit?.();
413424
}
414425

@@ -434,16 +445,6 @@ class EagerMapTreeOptionalField
434445
{
435446
public readonly editor = {
436447
set: (newContent: ExclusiveMapTree | undefined): void => {
437-
// If the new content is a UnhydratedFlexTreeNode, it needs to have its parent pointer updated
438-
if (newContent !== undefined) {
439-
nodeCache.get(newContent)?.adoptBy(this, 0);
440-
}
441-
// If the old content is a UnhydratedFlexTreeNode, it needs to have its parent pointer unset
442-
const oldContent = this.mapTrees[0];
443-
if (oldContent !== undefined) {
444-
nodeCache.get(oldContent)?.adoptBy(undefined);
445-
}
446-
447448
this.edit((mapTrees) => {
448449
if (newContent !== undefined) {
449450
mapTrees[0] = newContent;
@@ -484,10 +485,8 @@ export class UnhydratedTreeSequenceField
484485
{
485486
public readonly editor: UnhydratedTreeSequenceFieldEditBuilder = {
486487
insert: (index, newContent): void => {
487-
for (let i = 0; i < newContent.length; i++) {
488-
const c = newContent[i];
488+
for (const c of newContent) {
489489
assert(c !== undefined, 0xa0a /* Unexpected sparse array content */);
490-
nodeCache.get(c)?.adoptBy(this, index + i);
491490
}
492491
this.edit((mapTrees) => {
493492
if (newContent.length < 1000) {
@@ -503,7 +502,6 @@ export class UnhydratedTreeSequenceField
503502
for (let i = index; i < index + count; i++) {
504503
const c = this.mapTrees[i];
505504
assert(c !== undefined, 0xa0b /* Unexpected sparse array */);
506-
nodeCache.get(c)?.adoptBy(undefined);
507505
}
508506
let removed: ExclusiveMapTree[] | undefined;
509507
this.edit((mapTrees) => {

packages/dds/tree/src/test/simple-tree/api/treeNodeApi.spec.ts

Lines changed: 70 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import {
3535
TestTreeProviderLite,
3636
validateUsageError,
3737
} from "../../utils.js";
38-
import { getViewForForkedBranch, hydrate } from "../utils.js";
38+
import { describeHydration, getViewForForkedBranch, hydrate } from "../utils.js";
3939
import { brand, type areSafelyAssignable, type requireTrue } from "../../../util/index.js";
4040

4141
import {
@@ -165,39 +165,70 @@ describe("treeNodeApi", () => {
165165
});
166166
});
167167

168-
it("key", () => {
169-
class Child extends schema.object("Child", {
170-
x: Point,
171-
y: schema.optional(Point, { key: "stable-y" }),
172-
}) {}
173-
const Root = schema.array(Child);
174-
const config = new TreeViewConfiguration({ schema: Root });
175-
const view = getView(config);
176-
view.initialize([
177-
{ x: {}, y: undefined },
178-
{ x: {}, y: {} },
179-
]);
180-
const { root } = view;
181-
assert.equal(Tree.key(root), rootFieldKey);
182-
assert.equal(Tree.key(root[0]), 0);
183-
assert.equal(Tree.key(root[0].x), "x");
184-
assert.equal(Tree.key(root[1]), 1);
185-
assert.equal(Tree.key(root[1].x), "x");
186-
assert(root[1].y !== undefined);
187-
assert.equal(Tree.key(root[1].y), "y");
188-
});
168+
describeHydration("upward path", (init) => {
169+
for (const [name, keyApi] of [
170+
["key", (n: TreeNode): string | undefined | number => Tree.key(n)],
171+
["key2", (n: TreeNode): string | undefined | number => TreeAlpha.key2(n)],
172+
] as const) {
173+
it(name, () => {
174+
class Child extends schema.object("Child", {
175+
x: Point,
176+
y: schema.optional(Point, { key: "stable-y" }),
177+
}) {}
178+
class Root extends schema.array("Root", Child) {}
179+
const root = init(Root, [
180+
{ x: {}, y: undefined },
181+
{ x: {}, y: {} },
182+
]);
189183

190-
it("parent", () => {
191-
class Child extends schema.object("Child", { x: Point }) {}
192-
const Root = schema.array(Child);
193-
const config = new TreeViewConfiguration({ schema: Root });
194-
const view = getView(config);
195-
view.initialize([{ x: {} }, { x: {} }]);
196-
const { root } = view;
197-
assert.equal(Tree.parent(root), undefined);
198-
assert.equal(Tree.parent(root[0]), root);
199-
assert.equal(Tree.parent(root[1]), root);
200-
assert.equal(Tree.parent(root[1].x), root[1]);
184+
// This is this how we handle root keys.
185+
// Seems odd for detached fields other than root to have `rootFieldKey` key though.
186+
// Exactly which key is given in this case is undocumented, it could change in the future.
187+
// TreeAlpha.key2 just gives undefined, which is documented.
188+
const rootKey = name === "key" ? rootFieldKey : undefined;
189+
190+
assert.equal(keyApi(root), rootKey);
191+
assert.equal(keyApi(root[0]), 0);
192+
assert.equal(keyApi(root[0].x), "x");
193+
assert.equal(keyApi(root[1]), 1);
194+
assert.equal(keyApi(root[1].x), "x");
195+
assert(root[1].y !== undefined);
196+
assert.equal(keyApi(root[1].y), "y");
197+
198+
const added = new Child({ x: {}, y: {} });
199+
200+
assert.equal(keyApi(added), rootKey);
201+
202+
// Check index is updated after insert.
203+
root.insertAtStart(added);
204+
assert.equal(keyApi(root[2]), 2);
205+
assert.equal(keyApi(added), 0);
206+
207+
// Check index is updated after removal.
208+
root.removeRange(0, 1);
209+
assert.equal(keyApi(root[1]), 1);
210+
assert.equal(keyApi(added), rootKey);
211+
});
212+
}
213+
214+
it("parent", () => {
215+
class Child extends schema.object("Child", { x: Point }) {}
216+
class Root extends schema.array("Root", Child) {}
217+
const root = init(Root, [{ x: {} }, { x: {} }]);
218+
219+
assert.equal(Tree.parent(root), undefined);
220+
assert.equal(Tree.parent(root[0]), root);
221+
assert.equal(Tree.parent(root[1]), root);
222+
assert.equal(Tree.parent(root[1].x), root[1]);
223+
224+
const added = new Child({ x: {} });
225+
226+
assert.equal(Tree.parent(added), undefined);
227+
root.insertAtStart(added);
228+
assert.equal(Tree.parent(added), root);
229+
root.removeRange(0, 1);
230+
assert.equal(Tree.parent(added), undefined);
231+
});
201232
});
202233

203234
it("treeStatus", () => {
@@ -215,29 +246,13 @@ describe("treeNodeApi", () => {
215246
assert.equal(Tree.status(root), TreeStatus.InDocument);
216247
assert.equal(Tree.status(child), TreeStatus.Removed);
217248
assert.equal(Tree.status(newChild), TreeStatus.InDocument);
218-
// TODO: test Deleted status.
219-
});
220249

221-
it("key2", () => {
222-
class Child extends schema.object("Child", {
223-
x: Point,
224-
y: schema.optional(Point, { key: "stable-y" }),
225-
}) {}
226-
const Root = schema.array(Child);
227-
const config = new TreeViewConfiguration({ schema: Root });
228-
const view = getView(config);
229-
view.initialize([
230-
{ x: {}, y: undefined },
231-
{ x: {}, y: {} },
232-
]);
233-
const { root } = view;
234-
assert.equal(TreeAlpha.key2(root), undefined);
235-
assert.equal(TreeAlpha.key2(root[0]), 0);
236-
assert.equal(TreeAlpha.key2(root[0].x), "x");
237-
assert.equal(TreeAlpha.key2(root[1]), 1);
238-
assert.equal(TreeAlpha.key2(root[1].x), "x");
239-
assert(root[1].y !== undefined);
240-
assert.equal(TreeAlpha.key2(root[1].y), "y");
250+
view.dispose();
251+
assert.equal(Tree.status(root), TreeStatus.Deleted);
252+
assert.equal(Tree.status(child), TreeStatus.Deleted);
253+
assert.equal(Tree.status(newChild), TreeStatus.Deleted);
254+
255+
// TODO: test Deleted status when caused by removal from the tree + expiring from removed status.
241256
});
242257

243258
describe("shortID", () => {

0 commit comments

Comments
 (0)