Skip to content

Commit 29718a0

Browse files
authored
Add re-entrancy errors for TreeCheckout (#23099)
## Description This prevents the user from doing various actions to a SharedTree view while in the middle of an ongoing tree edit. The current behavior for these scenarios is undefined and currently tends to give unpredictable internal error messages to users.
1 parent db760b4 commit 29718a0

File tree

3 files changed

+186
-2
lines changed

3 files changed

+186
-2
lines changed

packages/dds/tree/src/shared-tree/schematizingTreeView.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,7 @@ export class SchematizingSimpleTreeView<
383383

384384
// #region Branching
385385

386-
public fork(): ReturnType<TreeBranch["fork"]> & TreeViewAlpha<TRootSchema> {
386+
public fork(): ReturnType<TreeBranch["fork"]> & SchematizingSimpleTreeView<TRootSchema> {
387387
return this.checkout.branch().viewWith(this.config);
388388
}
389389

packages/dds/tree/src/shared-tree/treeCheckout.ts

+112-1
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import type {
7373
UnsafeUnknownSchema,
7474
ViewableTree,
7575
TreeBranch,
76+
TreeChangeEvents,
7677
} from "../simple-tree/index.js";
7778
import { getCheckout, SchematizingSimpleTreeView } from "./schematizingTreeView.js";
7879

@@ -342,6 +343,8 @@ export interface RevertMetrics {
342343
export class TreeCheckout implements ITreeCheckoutFork {
343344
public disposed = false;
344345

346+
private readonly editLock: EditLock;
347+
345348
private readonly views = new Set<TreeView<ImplicitFieldSchema>>();
346349

347350
/**
@@ -432,6 +435,8 @@ export class TreeCheckout implements ITreeCheckoutFork {
432435
},
433436
);
434437

438+
this.editLock = new EditLock(this.#transaction.activeBranchEditor);
439+
435440
branch.events.on("afterChange", (event) => {
436441
// The following logic allows revertibles to be generated for the change.
437442
// Currently only appends (including merges and transaction commits) are supported.
@@ -484,6 +489,7 @@ export class TreeCheckout implements ITreeCheckoutFork {
484489
}
485490

486491
private readonly onAfterChange = (event: SharedTreeBranchChange<SharedTreeChange>): void => {
492+
this.editLock.lock();
487493
this.#events.emit("beforeBatch", event);
488494
if (event.change !== undefined) {
489495
const revision =
@@ -521,6 +527,7 @@ export class TreeCheckout implements ITreeCheckoutFork {
521527
}
522528
}
523529
this.#events.emit("afterBatch");
530+
this.editLock.unlock();
524531
if (event.type === "append") {
525532
event.newCommits.forEach((commit) => this.validateCommit(commit));
526533
}
@@ -665,7 +672,7 @@ export class TreeCheckout implements ITreeCheckoutFork {
665672

666673
public get editor(): ISharedTreeEditor {
667674
this.checkNotDisposed();
668-
return this.#transaction.activeBranchEditor;
675+
return this.editLock.editor;
669676
}
670677

671678
public locate(anchor: Anchor): AnchorNode | undefined {
@@ -692,6 +699,7 @@ export class TreeCheckout implements ITreeCheckoutFork {
692699
this.checkNotDisposed(
693700
"The parent branch has already been disposed and can no longer create new branches.",
694701
);
702+
this.editLock.checkUnlocked("Branching");
695703
const anchors = new AnchorSet();
696704
const branch = this.#transaction.activeBranch.fork();
697705
const storedSchema = this.storedSchema.clone();
@@ -720,6 +728,7 @@ export class TreeCheckout implements ITreeCheckoutFork {
720728
checkout.checkNotDisposed(
721729
"The source of the branch rebase has been disposed and cannot be rebased.",
722730
);
731+
this.editLock.checkUnlocked("Rebasing");
723732
assert(
724733
!checkout.transaction.isInProgress(),
725734
0x9af /* A view cannot be rebased while it has a pending transaction */,
@@ -748,6 +757,7 @@ export class TreeCheckout implements ITreeCheckoutFork {
748757
checkout.checkNotDisposed(
749758
"The source of the branch merge has been disposed and cannot be merged.",
750759
);
760+
this.editLock.checkUnlocked("Merging");
751761
assert(
752762
!this.transaction.isInProgress(),
753763
0x9b0 /* Views cannot be merged into a view while it has a pending transaction */,
@@ -768,6 +778,7 @@ export class TreeCheckout implements ITreeCheckoutFork {
768778
}
769779

770780
public dispose(): void {
781+
this.editLock.checkUnlocked("Disposing a view");
771782
this[disposeSymbol]();
772783
}
773784

@@ -955,3 +966,103 @@ export class TreeCheckout implements ITreeCheckoutFork {
955966

956967
// #endregion Commit Validation
957968
}
969+
970+
/**
971+
* A helper class that assists {@link TreeCheckout} in preventing functionality from being used while the tree is in the middle of being edited.
972+
*/
973+
class EditLock {
974+
/**
975+
* Edits the tree by calling the methods of the editor passed into the {@link EditLock} constructor.
976+
* @remarks Edits will throw an error if the lock is currently locked.
977+
*/
978+
public editor: ISharedTreeEditor;
979+
private locked = false;
980+
981+
/**
982+
* @param editor - an editor which will be used to create a new editor that is monitored to determine if any changes are happening to the tree.
983+
* Use {@link EditLock.editor} in place of the original editor to ensure that changes are monitored.
984+
*/
985+
public constructor(editor: ISharedTreeEditor) {
986+
const checkLock = (): void => this.checkUnlocked("Editing the tree");
987+
this.editor = {
988+
get schema() {
989+
return editor.schema;
990+
},
991+
valueField(...fieldArgs) {
992+
const valueField = editor.valueField(...fieldArgs);
993+
return {
994+
set(...editArgs) {
995+
checkLock();
996+
valueField.set(...editArgs);
997+
},
998+
};
999+
},
1000+
optionalField(...fieldArgs) {
1001+
const optionalField = editor.optionalField(...fieldArgs);
1002+
return {
1003+
set(...editArgs) {
1004+
checkLock();
1005+
optionalField.set(...editArgs);
1006+
},
1007+
};
1008+
},
1009+
sequenceField(...fieldArgs) {
1010+
const sequenceField = editor.sequenceField(...fieldArgs);
1011+
return {
1012+
insert(...editArgs) {
1013+
checkLock();
1014+
sequenceField.insert(...editArgs);
1015+
},
1016+
remove(...editArgs) {
1017+
checkLock();
1018+
sequenceField.remove(...editArgs);
1019+
},
1020+
};
1021+
},
1022+
move(...moveArgs) {
1023+
checkLock();
1024+
editor.move(...moveArgs);
1025+
},
1026+
addNodeExistsConstraint(path) {
1027+
editor.addNodeExistsConstraint(path);
1028+
},
1029+
};
1030+
}
1031+
1032+
/**
1033+
* Prevent further changes from being made to {@link EditLock.editor} until {@link EditLock.unlock} is called.
1034+
* @remarks May only be called when the lock is not already locked.
1035+
*/
1036+
public lock(): void {
1037+
if (this.locked) {
1038+
debugger;
1039+
}
1040+
assert(!this.locked, "Checkout has already been locked");
1041+
this.locked = true;
1042+
}
1043+
1044+
/**
1045+
* Throws an error if the lock is currently locked.
1046+
* @param action - The current action being performed by the user.
1047+
* This must start with a capital letter, as it shows up as the first part of the error message and we want it to look nice.
1048+
*/
1049+
public checkUnlocked<T extends string>(action: T extends Capitalize<T> ? T : never): void {
1050+
if (this.locked) {
1051+
// These type assertions ensure that the event name strings used here match the actual event names
1052+
const nodeChanged: keyof TreeChangeEvents = "nodeChanged";
1053+
const treeChanged: keyof TreeChangeEvents = "treeChanged";
1054+
throw new UsageError(
1055+
`${action} is forbidden during a ${nodeChanged} or ${treeChanged} event`,
1056+
);
1057+
}
1058+
}
1059+
1060+
/**
1061+
* Allow changes to be made to {@link EditLock.editor} again.
1062+
* @remarks May only be called when the lock is currently locked.
1063+
*/
1064+
public unlock(): void {
1065+
assert(this.locked, "Checkout has not been locked");
1066+
this.locked = false;
1067+
}
1068+
}

packages/dds/tree/src/test/shared-tree/treeCheckout.spec.ts

+73
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
getOrCreateInnerNode,
5252
toStoredSchema,
5353
type InsertableField,
54+
type TreeBranch,
5455
} from "../../simple-tree/index.js";
5556
// eslint-disable-next-line import/no-internal-modules
5657
import { stringSchema } from "../../simple-tree/leafNodeSchema.js";
@@ -1268,6 +1269,78 @@ describe("sharedTreeView", () => {
12681269
});
12691270
}
12701271
});
1272+
1273+
describe("throws an error if it is in the middle of an edit when a user attempts to", () => {
1274+
const sf = new SchemaFactory("Checkout and view test schema");
1275+
class NumberNode extends sf.object("Number", { number: sf.number }) {}
1276+
1277+
/** Tests that an error is thrown when a given action is taken during the execution of a nodeChanged/treeChanged listener */
1278+
function expectErrorDuringEdit(args: {
1279+
/**
1280+
* Runs after the main view has been created but before the edit occurs
1281+
* @returns (optionally) a view (e.g. a fork of the main view) that will be passed to `duringEdit`
1282+
*/
1283+
setup?: (
1284+
view: SchematizingSimpleTreeView<typeof NumberNode>,
1285+
) => void | SchematizingSimpleTreeView<typeof NumberNode>;
1286+
/** The code to run during the edit that should throw an error */
1287+
duringEdit: (view: SchematizingSimpleTreeView<typeof NumberNode>) => void;
1288+
/** The expected error message */
1289+
error: string;
1290+
}): void {
1291+
let view = getView(
1292+
new TreeViewConfiguration({ enableSchemaValidation, schema: NumberNode }),
1293+
);
1294+
1295+
view.initialize({ number: 3 });
1296+
view = args.setup?.(view) ?? view;
1297+
1298+
Tree.on(view.root, "nodeChanged", () => {
1299+
args.duringEdit(view);
1300+
});
1301+
1302+
assert.throws(() => (view.root.number = 0), new RegExp(args.error));
1303+
}
1304+
1305+
it("edit the tree", () => {
1306+
expectErrorDuringEdit({
1307+
duringEdit: (view) => {
1308+
view.root.number = 4;
1309+
},
1310+
error: "Editing the tree is forbidden during a nodeChanged or treeChanged event",
1311+
});
1312+
});
1313+
1314+
it("create a branch", () => {
1315+
expectErrorDuringEdit({
1316+
duringEdit: (view) => view.fork(),
1317+
error: ".*Branching is forbidden during a nodeChanged or treeChanged event.*",
1318+
});
1319+
});
1320+
1321+
it("rebase a branch", () => {
1322+
expectErrorDuringEdit({
1323+
duringEdit: (view) => view.rebaseOnto(view),
1324+
error: "Rebasing is forbidden during a nodeChanged or treeChanged event",
1325+
});
1326+
});
1327+
1328+
it("merge a branch", () => {
1329+
expectErrorDuringEdit({
1330+
duringEdit: (view) => view.merge(view),
1331+
error: "Merging is forbidden during a nodeChanged or treeChanged event",
1332+
});
1333+
});
1334+
1335+
it("dispose", () => {
1336+
let branch: TreeBranch | undefined;
1337+
expectErrorDuringEdit({
1338+
setup: (view) => (branch = view.fork()), // Create a fork of the view because the main view can't be disposed
1339+
duringEdit: (view) => view.dispose(),
1340+
error: "Disposing a view is forbidden during a nodeChanged or treeChanged event",
1341+
});
1342+
});
1343+
});
12711344
});
12721345

12731346
const defaultSf = new SchemaFactory("Checkout and view test schema");

0 commit comments

Comments
 (0)