Skip to content
Open
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
43 changes: 43 additions & 0 deletions .changeset/tough-lies-say.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
"@fluidframework/tree": minor
"__section": tree
---
Starting a transaction inside a tree-change event listener now produces a usage error

Calling `runTransaction` or `runTransactionAsync` from inside a `nodeChanged` or `treeChanged` event listener now throws a `UsageError`.
This brings transactions into line with the existing rule that tree mutations from inside a change-event listener are forbidden.

Before this change, starting a transaction from a listener would push a transaction frame onto the running outer transaction's bookkeeping before the inner edit was attempted,

Check warning on line 10 in .changeset/tough-lies-say.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [Microsoft.SentenceLength] Try to keep sentences short (< 30 words). Raw Output: {"message": "[Microsoft.SentenceLength] Try to keep sentences short (\u003c 30 words).", "location": {"path": ".changeset/tough-lies-say.md", "range": {"start": {"line": 10, "column": 1}}}, "severity": "INFO"}
leaving the tree's transaction labels in a corrupted state even when the inner edit itself threw.
The new check rejects the call before any state mutation, so the tree's transaction labels remain consistent.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this leave the tree in a broken state, so it's impossible for it to matter if the transaction labels are consistent or not?

Might be better to rephase this to something like:

Suggested change
The new check rejects the call before any state mutation, so the tree's transaction labels remain consistent.
The updated code throws a `UsageError` before it can corrupt anything.


The error message follows the existing format used by the other listener-time guards:

```
Running a transaction is forbidden during a nodeChanged or treeChanged event
```

After this error is thrown, the affected tree view enters a broken state and subsequent operations on it will throw an `Invalid use of ... after it was put into an invalid state` error.
Discard the view and acquire a fresh one to recover.

#### Migration
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What actually worked before? Could you make transactions, but they would crash if there were any edits in them (in which case they could only ne no-ops), or could you actually slip transactions with real edits inside of them thorough our validation before this? If only the no-op case worked, I don't think there is anything to migrate, simply don't make no-op transactions inside events.


If you start a transaction in response to a tree change, defer it out of the listener:

```typescript
// Before — throws at runtime
Tree.on(view.root, "nodeChanged", () => {
view.runTransaction(() => {
// ...
});
});

// After — schedule the transaction outside the listener
Tree.on(view.root, "nodeChanged", () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should recommend this approach to people. Its bad for a few reasons:

  1. If you do edits in response to document changes, its very easy to end up with edits triggering edits in a loop (on one client or between multiple clients) resulting in infinite edits, consuming unbounded bandwidth, storage, battery life CPU etc.
  2. Its easy to end up with multiple edits, one from each client connected, fighting over making the change (even if all but the first are no-ops due to merge resolution, its inefficient)
  3. It can lead to screwy attribution as the document show up last changed by a user who did not personally make the change
  4. It can screw up undo/redo, as it creates a change by the current user, that was not trigger by any of that user's actual actions.
  5. Its async, so its possible the edit trying to be done is no longer valid (a change could have happened in-between), the view could have been disposed, etc. If you need to do a change asynchronously, one should use a branch instead of holding onto the view or nodes async (unless they setup invalidation for the values they are holding onto which this example does not do).

I think we can simply say applications should not make edits in response to edits.

queueMicrotask(() => {
view.runTransaction(() => {
// ...
});
});
});
```
5 changes: 5 additions & 0 deletions packages/dds/tree/src/shared-tree/treeCheckout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,11 @@ export class TreeCheckout implements ITreeCheckout {

private mountTransaction(params: RunTransactionParams | undefined, isAsync: boolean): void {
this.checkNotDisposed();
// Starting a transaction during a nodeChanged/treeChanged event is forbidden for the same
// reason direct edits are: it would push a label frame and (typically) commit edits while
// the tree's invariants are mid-flight. Without this guard, nested transactions started
// from a listener leak label frames into the running outer transaction's label tree
this.editLock.checkUnlocked("Running a transaction");
if (isAsync && this.transaction.size > 0) {
throw new UsageError(
"An asynchronous transaction cannot be started while another transaction is already in progress.",
Expand Down
81 changes: 81 additions & 0 deletions packages/dds/tree/src/test/shared-tree/treeCheckout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1357,6 +1357,87 @@ describe("sharedTreeView", () => {
error: "Disposing a view is forbidden during a nodeChanged or treeChanged event",
});
});

it("run a transaction", () => {
// Without the guard, a runTransaction call from inside a listener pushes a label
// frame and starts a nested transaction before the inner edit's lock check fires,
// corrupting the outer transaction's label tree. The guard rejects at mount time.
expectErrorDuringEdit({
duringEdit: (view) =>
view.runTransaction(() => {
view.root.number = 4;
}),
error: "Running a transaction is forbidden during a nodeChanged or treeChanged event",
});
Comment on lines +1365 to +1371
});

it("run an async transaction", async () => {
// runTransactionAsync is declared `async`, so the synchronous throw from
// mountTransaction surfaces as a rejected promise rather than a thrown error.
// Capture the promise in the listener and assert rejection after the outer edit.
const view = getView(
new TreeViewConfiguration({ enableSchemaValidation, schema: NumberNode }),
);
view.initialize({ number: 3 });

let asyncResult: Promise<unknown> | undefined;
Tree.on(view.root, "nodeChanged", () => {
asyncResult = view.runTransactionAsync(async () => {
view.root.number = 4;
});
});

view.root.number = 0;
assert(asyncResult !== undefined, "Expected the listener to start a transaction");
await assert.rejects(
asyncResult,
validateUsageError(
/Running a transaction is forbidden during a nodeChanged or treeChanged event/,
),
);
});

it("leaves the tree in a broken state after run-transaction-during-listener throws", () => {
// runTransaction is decorated with `@breakingMethod`, so a throw from inside it
// (here: the new `checkUnlocked` call in `mountTransaction`) puts the tree into
// the `Breakable` "broken" state. After that, every method decorated with
// `@breakingMethod` / `@throwIfBroken` rejects with "Invalid use of ... after it
// was put into an invalid state". This test documents that contract so future
// refactors that move `mountTransaction` outside the decorated method's reach
// (or remove the decorator) fail loudly.
const view = getView(
new TreeViewConfiguration({ enableSchemaValidation, schema: NumberNode }),
);
view.initialize({ number: 3 });

Tree.on(view.root, "nodeChanged", () => {
view.runTransaction(() => {
view.root.number = 4;
});
});

assert.throws(
() => (view.root.number = 0),
validateUsageError(
/Running a transaction is forbidden during a nodeChanged or treeChanged event/,
),
);

// Subsequent operations should all throw with the broken-state message.
assert.throws(
() => (view.root.number = 5),
validateUsageError(/invalid state/),
"direct edit after listener throw should be blocked",
);
assert.throws(
() =>
view.runTransaction(() => {
view.root.number = 6;
}),
validateUsageError(/invalid state/),
"runTransaction after listener throw should be blocked",
);
Comment on lines +1426 to +1439
});
});

describe("Enrichment", () => {
Expand Down
Loading