diff --git a/packages/dds/tree/api-report/tree.alpha.api.md b/packages/dds/tree/api-report/tree.alpha.api.md index e54501b3baf9..332be1e57045 100644 --- a/packages/dds/tree/api-report/tree.alpha.api.md +++ b/packages/dds/tree/api-report/tree.alpha.api.md @@ -1073,12 +1073,21 @@ export interface RunTransaction { readonly rollback: typeof rollback; } +// @alpha @input +export interface RunTransactionAsyncParams extends RunTransactionParams { +} + // @alpha @input export interface RunTransactionParams { readonly label?: unknown; readonly preconditions?: readonly TransactionConstraintAlpha[]; } +// @alpha @input +export interface RunTransactionSyncParams extends RunTransactionParams { + readonly deferEvents?: boolean; +} + // @public @sealed export interface SchemaCompatibilityStatus { readonly canInitialize: boolean; @@ -1739,10 +1748,10 @@ export interface TreeBranchAlpha extends TreeBranch, TreeContextAlpha { // (undocumented) fork(): TreeBranchAlpha; hasRootSchema(schema: TSchema): this is TreeViewAlpha; - runTransaction(transaction: () => TransactionCallbackStatus, params?: RunTransactionParams): TransactionResultExt; - runTransaction(transaction: () => VoidTransactionCallbackStatus | void, params?: RunTransactionParams): TransactionResult; - runTransactionAsync(transaction: () => Promise>, params?: RunTransactionParams): Promise>; - runTransactionAsync(transaction: () => Promise, params?: RunTransactionParams): Promise; + runTransaction(transaction: () => TransactionCallbackStatus, params?: RunTransactionSyncParams): TransactionResultExt; + runTransaction(transaction: () => VoidTransactionCallbackStatus | void, params?: RunTransactionSyncParams): TransactionResult; + runTransactionAsync(transaction: () => Promise>, params?: RunTransactionAsyncParams): Promise>; + runTransactionAsync(transaction: () => Promise, params?: RunTransactionAsyncParams): Promise; } // @alpha @sealed @@ -1777,10 +1786,10 @@ export enum TreeCompressionStrategy { // @alpha export interface TreeContextAlpha { isBranch(): this is TreeBranchAlpha; - runTransaction(transaction: () => WithValue, params?: RunTransactionParams): TransactionResultExt; - runTransaction(transaction: () => void, params?: RunTransactionParams): TransactionResult; - runTransactionAsync(transaction: () => Promise>, params?: RunTransactionParams): Promise>; - runTransactionAsync(transaction: () => Promise, params?: RunTransactionParams): Promise; + runTransaction(transaction: () => WithValue, params?: RunTransactionSyncParams): TransactionResultExt; + runTransaction(transaction: () => void, params?: RunTransactionSyncParams): TransactionResult; + runTransactionAsync(transaction: () => Promise>, params?: RunTransactionAsyncParams): Promise>; + runTransactionAsync(transaction: () => Promise, params?: RunTransactionAsyncParams): Promise; } // @beta @input diff --git a/packages/dds/tree/src/entrypoints/alpha.ts b/packages/dds/tree/src/entrypoints/alpha.ts index c3703d2f5692..740c6419f842 100644 --- a/packages/dds/tree/src/entrypoints/alpha.ts +++ b/packages/dds/tree/src/entrypoints/alpha.ts @@ -241,7 +241,9 @@ export { RemoteChangeMetadata, RevertibleAlpha, RevertibleAlphaFactory, + RunTransactionAsyncParams, RunTransactionParams, + RunTransactionSyncParams, SchemaFactoryAlpha, SchemaStaticsAlpha, SchemaType, diff --git a/packages/dds/tree/src/index.ts b/packages/dds/tree/src/index.ts index 1b3b09201dc1..0cca50c5e6e2 100644 --- a/packages/dds/tree/src/index.ts +++ b/packages/dds/tree/src/index.ts @@ -278,6 +278,8 @@ export { type NodeInDocumentConstraint, type NoChangeConstraint, type RunTransactionParams, + type RunTransactionAsyncParams, + type RunTransactionSyncParams, type VoidTransactionCallbackStatus, type TransactionCallbackStatus, type TransactionResult, diff --git a/packages/dds/tree/src/shared-tree/schematizingTreeView.ts b/packages/dds/tree/src/shared-tree/schematizingTreeView.ts index 6ddcde408ff4..47410856f49e 100644 --- a/packages/dds/tree/src/shared-tree/schematizingTreeView.ts +++ b/packages/dds/tree/src/shared-tree/schematizingTreeView.ts @@ -45,7 +45,8 @@ import { type TransactionCallbackStatus, type TransactionResult, type TransactionResultExt, - type RunTransactionParams, + type RunTransactionAsyncParams, + type RunTransactionSyncParams, HydratedContext, SimpleContextSlot, areImplicitFieldSchemaEqual, @@ -273,18 +274,18 @@ export class SchematizingSimpleTreeView< public runTransaction( transaction: () => TransactionCallbackStatus, - params?: RunTransactionParams, + params?: RunTransactionSyncParams, ): TransactionResultExt; public runTransaction( transaction: () => VoidTransactionCallbackStatus | void, - params?: RunTransactionParams, + params?: RunTransactionSyncParams, ): TransactionResult; public runTransaction( transaction: () => | TransactionCallbackStatus | VoidTransactionCallbackStatus | void, - params?: RunTransactionParams, + params?: RunTransactionSyncParams, ): TransactionResultExt | TransactionResult { this.ensureUndisposed(); return this.checkout.runTransaction(transaction, params); @@ -292,11 +293,11 @@ export class SchematizingSimpleTreeView< public runTransactionAsync( transaction: () => Promise>, - params?: RunTransactionParams, + params?: RunTransactionAsyncParams, ): Promise>; public runTransactionAsync( transaction: () => Promise, - params?: RunTransactionParams, + params?: RunTransactionAsyncParams, ): Promise; public async runTransactionAsync( transaction: () => Promise< @@ -304,7 +305,7 @@ export class SchematizingSimpleTreeView< | VoidTransactionCallbackStatus | void >, - params: RunTransactionParams | undefined, + params: RunTransactionAsyncParams | undefined, ): Promise | TransactionResult> { this.ensureUndisposed(); if (this.checkout.transaction.size > 0) { diff --git a/packages/dds/tree/src/shared-tree/treeCheckout.ts b/packages/dds/tree/src/shared-tree/treeCheckout.ts index 13ba631cafae..7cbb94aec5ff 100644 --- a/packages/dds/tree/src/shared-tree/treeCheckout.ts +++ b/packages/dds/tree/src/shared-tree/treeCheckout.ts @@ -97,6 +97,8 @@ import { type TransactionResult, type TransactionResultExt, type RunTransactionParams, + type RunTransactionAsyncParams, + type RunTransactionSyncParams, type TransactionConstraintAlpha, type TreeViewAlpha, getInnerNode, @@ -104,6 +106,7 @@ import { customFromCursorStored, type CustomTreeValue, type CustomTreeNode, + withBufferedTreeEvents, } from "../simple-tree/index.js"; import { Breakable, @@ -845,11 +848,11 @@ export class TreeCheckout implements ITreeCheckout { public runTransaction( transaction: () => TransactionCallbackStatus, - params?: RunTransactionParams, + params?: RunTransactionSyncParams, ): TransactionResultExt; public runTransaction( transaction: () => VoidTransactionCallbackStatus | void, - params?: RunTransactionParams, + params?: RunTransactionSyncParams, ): TransactionResult; @breakingMethod public runTransaction( @@ -857,20 +860,31 @@ export class TreeCheckout implements ITreeCheckout { | TransactionCallbackStatus | VoidTransactionCallbackStatus | void, - params?: RunTransactionParams, + params?: RunTransactionSyncParams, ): TransactionResultExt | TransactionResult { - this.mountTransaction(params, false); - const transactionCallbackStatus = transaction(); - return this.unmountTransaction(transactionCallbackStatus, params); + const transactionCore = (): + | TransactionResultExt + | TransactionResult => { + this.mountTransaction(params, false); + const transactionCallbackStatus = transaction(); + return this.unmountTransaction(transactionCallbackStatus, params); + }; + return params?.deferEvents === true + ? withBufferedTreeEvents(transactionCore, { + // On rollback the tree is restored to its starting state, so any buffered events + // represent net-zero changes and must not be surfaced to listeners. + shouldDiscard: (result) => !result.success, + }) + : transactionCore(); } public runTransactionAsync( transaction: () => Promise>, - params?: RunTransactionParams, + params?: RunTransactionAsyncParams, ): Promise>; public runTransactionAsync( transaction: () => Promise, - params?: RunTransactionParams, + params?: RunTransactionAsyncParams, ): Promise; @breakingMethod public async runTransactionAsync( @@ -879,7 +893,7 @@ export class TreeCheckout implements ITreeCheckout { | VoidTransactionCallbackStatus | void >, - params: RunTransactionParams | undefined, + params: RunTransactionAsyncParams | undefined, ): Promise | TransactionResult> { this.mountTransaction(params, true); const transactionCallbackStatus = await transaction(); diff --git a/packages/dds/tree/src/simple-tree/api/index.ts b/packages/dds/tree/src/simple-tree/api/index.ts index 4faf9e3749ce..2ad8ba062daf 100644 --- a/packages/dds/tree/src/simple-tree/api/index.ts +++ b/packages/dds/tree/src/simple-tree/api/index.ts @@ -184,6 +184,8 @@ export { type NodeInDocumentConstraint, type NoChangeConstraint, type RunTransactionParams, + type RunTransactionAsyncParams, + type RunTransactionSyncParams, type VoidTransactionCallbackStatus, type TransactionCallbackStatus, type TransactionResult, diff --git a/packages/dds/tree/src/simple-tree/api/transactionTypes.ts b/packages/dds/tree/src/simple-tree/api/transactionTypes.ts index 09e42bdfab1d..be48af92c78b 100644 --- a/packages/dds/tree/src/simple-tree/api/transactionTypes.ts +++ b/packages/dds/tree/src/simple-tree/api/transactionTypes.ts @@ -138,6 +138,7 @@ export interface RunTransactionParams { * If any of the constraints are not met after the transaction has been ordered by the service, it will be rolled back on this client and ignored by all other clients. */ readonly preconditions?: readonly TransactionConstraintAlpha[]; + /** * A label for this transaction that allows it to be correlated with later edits (e.g. for controlling undo/redo grouping). * @remarks @@ -147,3 +148,28 @@ export interface RunTransactionParams { */ readonly label?: unknown; } + +/** + * The parameters for the asynchronous {@link RunTransaction | runTransaction} API. + * @input + * @alpha + */ +export interface RunTransactionAsyncParams extends RunTransactionParams {} + +/** + * The parameters for the synchronous {@link RunTransaction | runTransaction} API. + * @input + * @alpha + */ +export interface RunTransactionSyncParams extends RunTransactionParams { + /** + * Set this to true to have the transaction's change events buffered and emitted only once the transaction completes. + * + * @remarks + * If the transaction rolls back *during the transaction*, no buffered events are emitted (the tree is unchanged). + * This does not apply to a later rollbacks (e.g. caused by post-sequencing constraint validation after `runTransaction` has returned). + * + * @defaultValue `false` + */ + readonly deferEvents?: boolean; +} diff --git a/packages/dds/tree/src/simple-tree/api/tree.ts b/packages/dds/tree/src/simple-tree/api/tree.ts index 4a98b29e28b8..8a95285f5c48 100644 --- a/packages/dds/tree/src/simple-tree/api/tree.ts +++ b/packages/dds/tree/src/simple-tree/api/tree.ts @@ -36,7 +36,8 @@ import type { UnsafeUnknownSchema } from "../unsafeUnknownSchema.js"; import type { TreeViewConfiguration } from "./configuration.js"; import type { - RunTransactionParams, + RunTransactionAsyncParams, + RunTransactionSyncParams, TransactionCallbackStatus, TransactionResult, TransactionResultExt, @@ -193,7 +194,7 @@ export interface TreeContextAlpha { * Run a synchronous transaction which groups sequential edits to the tree into a single atomic edit if possible. * @param transaction - A callback run during the transaction to perform user-supplied operations. * It may optionally return a {@link WithValue | value }, which will be returned by the `runTransaction` call. - * @param params - Optional {@link RunTransactionParams | parameters} for the transaction. + * @param params - Optional {@link RunTransactionSyncParams | parameters} for the transaction. * @returns A {@link TransactionResultExt | value } indicating whether or not the transaction succeeded, and containing the value returned by `transaction`. * @remarks * All of the changes in the transaction are applied synchronously and therefore no other changes from a remote client can be interleaved with those changes. @@ -215,11 +216,14 @@ export interface TreeContextAlpha { */ runTransaction( transaction: () => WithValue, - params?: RunTransactionParams, + params?: RunTransactionSyncParams, ): TransactionResultExt; /** An overload of {@link TreeContextAlpha.(runTransaction:1) | runTransaction } which does not return a value. */ - runTransaction(transaction: () => void, params?: RunTransactionParams): TransactionResult; + runTransaction( + transaction: () => void, + params?: RunTransactionSyncParams, + ): TransactionResult; /** * An asynchronous version of {@link TreeContextAlpha.(runTransaction:1) | runTransaction}. @@ -234,13 +238,13 @@ export interface TreeContextAlpha { */ runTransactionAsync( transaction: () => Promise>, - params?: RunTransactionParams, + params?: RunTransactionAsyncParams, ): Promise>; /** An overload of {@link TreeContextAlpha.(runTransactionAsync:1) | runTransactionAsync } which does not return a value. */ runTransactionAsync( transaction: () => Promise, - params?: RunTransactionParams, + params?: RunTransactionAsyncParams, ): Promise; /** @@ -301,7 +305,7 @@ export interface TreeBranchAlpha extends TreeBranch, TreeContextAlpha { */ runTransaction( transaction: () => TransactionCallbackStatus, - params?: RunTransactionParams, + params?: RunTransactionSyncParams, ): TransactionResultExt; /** @@ -309,7 +313,7 @@ export interface TreeBranchAlpha extends TreeBranch, TreeContextAlpha { */ runTransaction( transaction: () => VoidTransactionCallbackStatus | void, - params?: RunTransactionParams, + params?: RunTransactionSyncParams, ): TransactionResult; /** @@ -319,7 +323,7 @@ export interface TreeBranchAlpha extends TreeBranch, TreeContextAlpha { runTransactionAsync( transaction: () => Promise>, - params?: RunTransactionParams, + params?: RunTransactionAsyncParams, ): Promise>; /** @@ -327,7 +331,7 @@ export interface TreeBranchAlpha extends TreeBranch, TreeContextAlpha { */ runTransactionAsync( transaction: () => Promise, - params?: RunTransactionParams, + params?: RunTransactionAsyncParams, ): Promise; /** diff --git a/packages/dds/tree/src/simple-tree/core/treeNodeKernel.ts b/packages/dds/tree/src/simple-tree/core/treeNodeKernel.ts index bc2aa1481eaa..2d86e6c4ee7a 100644 --- a/packages/dds/tree/src/simple-tree/core/treeNodeKernel.ts +++ b/packages/dds/tree/src/simple-tree/core/treeNodeKernel.ts @@ -307,6 +307,24 @@ type KernelEvents = Pick; */ let bufferTreeEvents: boolean = false; +/** + * Options for {@link withBufferedTreeEvents}. + */ +export interface WithBufferedTreeEventsOptions { + /** + * Predicate invoked with the callback's return value after it completes. + * If it returns `true`, all events buffered during this call are discarded + * instead of being flushed. + * @remarks + * Only honored at the outermost call. When `withBufferedTreeEvents` is invoked + * while another call is already buffering, this option has no effect — the + * discard decision is owned by the outer call. + * + * If the callback throws, the predicate is not invoked and buffered events are flushed. + */ + readonly shouldDiscard?: (result: TResult) => boolean; +} + /** * Call the provided callback with {@link TreeNode}s' events paused until after the callback's completion. * @@ -316,27 +334,36 @@ let bufferTreeEvents: boolean = false; * @remarks * Note: this should be used with caution. User application behaviors are implicitly coupled to event timing. * Disrupting this timing can lead to unexpected behavior. + * @param callback - Function to invoke while events are buffered. + * @param options - Optional configuration. See {@link WithBufferedTreeEventsOptions}. */ -export function withBufferedTreeEvents(callback: () => void): void { +export function withBufferedTreeEvents( + callback: () => TResult, + options?: WithBufferedTreeEventsOptions, +): TResult { if (bufferTreeEvents) { - // Already buffering - just run the callback - callback(); - } else { - bufferTreeEvents = true; - try { - callback(); - } finally { - bufferTreeEvents = false; - flushEventsEmitter.emit("flush"); - } + // Already buffering - just run the callback. The outermost call owns the flush/discard decision. + return callback(); + } + bufferTreeEvents = true; + let discard = false; + try { + const result = callback(); + discard = options?.shouldDiscard?.(result) === true; + return result; + } finally { + bufferTreeEvents = false; + flushEventsEmitter.emit(discard ? "discard" : "flush"); } } /** - * Event emitter to notify subscribers when tree events buffered due to {@link withBufferedTreeEvents} should be flushed. + * Event emitter to notify subscribers when tree events buffered due to {@link withBufferedTreeEvents} should be flushed + * or discarded. */ const flushEventsEmitter = createEmitter<{ flush: () => void; + discard: () => void; }>(); /** @@ -351,6 +378,14 @@ class KernelEventBuffer implements Listenable { */ readonly #disposeOnFlushListener = flushEventsEmitter.on("flush", this.flush.bind(this)); + /** + * Listen to {@link flushEventsEmitter} to know when to discard buffered events. + */ + readonly #disposeOnDiscardListener = flushEventsEmitter.on( + "discard", + this.clearBuffers.bind(this), + ); + readonly #events = createEmitter(); #eventSource: Listenable & HasListeners; @@ -526,20 +561,40 @@ class KernelEventBuffer implements Listenable { public flush(): void { this.#assertNotDisposed(); + // TODO: The buffer tracks *which* fields changed during the window but not the net delta, + // so a sequence of edits that nets to no change within a committed transaction (e.g. an + // insert followed by a remove of the same item) still emits one event per affected field. + // Suppressing those would require delta composition support in the eventing stack; see the + // invalidation comment in #handleChildrenChangedAfterBatch for the related limitation. if (this.#childrenChangedBuffer.size > 0) { this.#events.emit("childrenChangedAfterBatch", { changedFields: this.#childrenChangedBuffer, fieldMarks: this.#fieldMarksBuffer, }); - this.#childrenChangedBuffer.clear(); - this.#fieldMarksBuffer.clear(); - this.#invalidatedFieldMarkKeys.clear(); } if (this.#subTreeChangedBuffer) { this.#events.emit("subtreeChangedAfterBatch"); - this.#subTreeChangedBuffer = false; } + + this.clearBuffers(); + } + + /** + * Discards any events buffered due to {@link withBufferedTreeEvents} without emitting them. + * + * @remarks + * Used by transaction code paths that know the tree is in the same state it started in + * (e.g. a rolled-back synchronous transaction) so the buffered events represent net-zero + * changes that should not be observed by listeners. + */ + public clearBuffers(): void { + this.#assertNotDisposed(); + + this.#childrenChangedBuffer.clear(); + this.#fieldMarksBuffer.clear(); + this.#invalidatedFieldMarkKeys.clear(); + this.#subTreeChangedBuffer = false; } #assertNotDisposed(): void { @@ -557,6 +612,7 @@ class KernelEventBuffer implements Listenable { ); this.#disposeOnFlushListener(); + this.#disposeOnDiscardListener(); for (const off of this.#disposeSourceListeners.values()) { off(); } diff --git a/packages/dds/tree/src/simple-tree/index.ts b/packages/dds/tree/src/simple-tree/index.ts index 3e4c36530889..15b14298525b 100644 --- a/packages/dds/tree/src/simple-tree/index.ts +++ b/packages/dds/tree/src/simple-tree/index.ts @@ -180,6 +180,8 @@ export { type NodeInDocumentConstraint, type NoChangeConstraint, type RunTransactionParams, + type RunTransactionAsyncParams, + type RunTransactionSyncParams, type VoidTransactionCallbackStatus, type TransactionCallbackStatus, type TransactionResult, diff --git a/packages/dds/tree/src/test/shared-tree/treeCheckout.spec.ts b/packages/dds/tree/src/test/shared-tree/treeCheckout.spec.ts index 09ca13834efb..afa41ab996f6 100644 --- a/packages/dds/tree/src/test/shared-tree/treeCheckout.spec.ts +++ b/packages/dds/tree/src/test/shared-tree/treeCheckout.spec.ts @@ -837,6 +837,249 @@ describe("sharedTreeView", () => { assert.deepEqual(view.root, ["A", "B"]); }); + + describe("deferEvents", () => { + itView("buffers nodeChanged events until the transaction commits", ({ view }) => { + const log: string[] = []; + Tree.on(view.root, "nodeChanged", () => log.push("nodeChanged")); + + view.runTransaction( + () => { + view.root.insertAtEnd("A"); + assert.deepEqual(log, [], "nodeChanged should not fire during the transaction"); + view.root.insertAtEnd("B"); + assert.deepEqual(log, [], "nodeChanged should not fire during the transaction"); + }, + { deferEvents: true }, + ); + + assert.deepEqual(log, ["nodeChanged"], "nodeChanged should fire once after commit"); + }); + + itView( + "coalesces multiple nodeChanged events into one when deferEvents is true", + ({ view }) => { + let nodeChangedCount = 0; + Tree.on(view.root, "nodeChanged", () => nodeChangedCount++); + + // Without buffering, two inserts → two nodeChanged events. + // With buffering, they coalesce into one. + view.runTransaction( + () => { + view.root.insertAtEnd("A"); + view.root.insertAtEnd("B"); + }, + { deferEvents: true }, + ); + + assert.equal(nodeChangedCount, 1); + }, + ); + + itView( + "unbuffered rollback fires two nodeChanged events (edit then revert)", + ({ view }) => { + // Baseline: without `deferEvents`, the insert and the abort each fire their + // own `nodeChanged` event — two events for a net-zero change. + const log: string[] = []; + Tree.on(view.root, "nodeChanged", () => log.push("nodeChanged")); + + view.runTransaction( + () => { + view.root.insertAtEnd("A"); + return { rollback: true }; + }, + { deferEvents: false }, + ); + + assert.deepEqual(view.root, []); + assert.deepEqual(log, ["nodeChanged", "nodeChanged"]); + }, + ); + + itView("emits no nodeChanged events for a rolled-back transaction", ({ view }) => { + // Without buffering, a rolled-back transaction emits TWO events: one when the edit + // is applied and a second when the abort reverses it. With deferEvents both are + // captured by the buffer; because the tree ends in its starting state the + // runTransaction wrapper discards the buffer entirely instead of flushing it. + const log: string[] = []; + Tree.on(view.root, "nodeChanged", () => log.push("nodeChanged")); + + view.runTransaction( + () => { + view.root.insertAtEnd("A"); + return { rollback: true }; + }, + { deferEvents: true }, + ); + + assert.deepEqual(view.root, []); + assert.deepEqual(log, []); + }); + + itView( + "fires nodeChanged normally (unbuffered) when deferEvents is false", + ({ view }) => { + const log: string[] = []; + Tree.on(view.root, "nodeChanged", () => log.push("nodeChanged")); + + view.runTransaction(() => { + view.root.insertAtEnd("A"); + view.root.insertAtEnd("B"); + }); + + assert.deepEqual(log, ["nodeChanged", "nodeChanged"]); + }, + ); + + itView( + "nested transactions: outer deferEvents coalesces events from inner transaction", + ({ view }) => { + let nodeChangedCount = 0; + Tree.on(view.root, "nodeChanged", () => nodeChangedCount++); + + view.runTransaction( + () => { + view.runTransaction(() => { + view.root.insertAtEnd("A"); + view.root.insertAtEnd("B"); + }); + view.root.insertAtEnd("C"); + }, + { deferEvents: true }, + ); + + assert.equal(nodeChangedCount, 1); + assert.deepEqual(view.root, ["A", "B", "C"]); + }, + ); + + // treeChanged tests — mirrors the nodeChanged tests above but for the subtree event. + + itView("buffers treeChanged events until the transaction commits", ({ view }) => { + const log: string[] = []; + Tree.on(view.root, "treeChanged", () => log.push("treeChanged")); + + view.runTransaction( + () => { + view.root.insertAtEnd("A"); + assert.deepEqual(log, [], "treeChanged should not fire during the transaction"); + view.root.insertAtEnd("B"); + assert.deepEqual(log, [], "treeChanged should not fire during the transaction"); + }, + { deferEvents: true }, + ); + + assert.deepEqual(log, ["treeChanged"], "treeChanged should fire once after commit"); + }); + + itView( + "coalesces multiple treeChanged events into one when deferEvents is true", + ({ view }) => { + let treeChangedCount = 0; + Tree.on(view.root, "treeChanged", () => treeChangedCount++); + + view.runTransaction( + () => { + view.root.insertAtEnd("A"); + view.root.insertAtEnd("B"); + }, + { deferEvents: true }, + ); + + assert.equal(treeChangedCount, 1); + }, + ); + + itView("emits no treeChanged events for a rolled-back transaction", ({ view }) => { + const log: string[] = []; + Tree.on(view.root, "treeChanged", () => log.push("treeChanged")); + + view.runTransaction( + () => { + view.root.insertAtEnd("A"); + return { rollback: true }; + }, + { deferEvents: true }, + ); + + assert.deepEqual(view.root, []); + assert.deepEqual(log, []); + }); + + // Nested-schema tests — verify treeChanged behavior that diverges from nodeChanged. + // For an object with a child array, modifying the child array fires: + // - nodeChanged on the child array (direct change) + // - treeChanged on the parent object (subtree change) + // but does NOT fire nodeChanged on the parent object (the "items" field was not reassigned). + + const sfBE = new SchemaFactory("deferEvents treeChanged tests"); + const BEItemsArray = sfBE.array("Items", sfBE.string); + const BERoot = sfBE.object("Root", { items: BEItemsArray }); + + itView( + "treeChanged on an ancestor node is buffered when a descendant changes", + ({ view }) => { + const log: string[] = []; + const root = view.root; + // nodeChanged on the root object does NOT fire here because the "items" field + // is not reassigned; only the array's contents change. + Tree.on(root, "nodeChanged", () => log.push("nodeChanged")); + Tree.on(root, "treeChanged", () => log.push("treeChanged")); + Tree.on(root.items, "nodeChanged", () => log.push("items.nodeChanged")); + + view.runTransaction( + () => { + root.items.insertAtEnd("A"); + root.items.insertAtEnd("B"); + assert.deepEqual(log, [], "no events should fire during the transaction"); + }, + { deferEvents: true }, + ); + + assert.deepEqual([...root.items], ["A", "B"]); + // items.nodeChanged fires once (two inserts coalesced). + // treeChanged fires once on root (subtree changed). + // nodeChanged does NOT fire on root (the "items" field was not reassigned). + assert.deepEqual(log, ["treeChanged", "items.nodeChanged"]); + }, + { + initialContent: { + schema: BERoot, + initialTree: { items: [] }, + }, + }, + ); + + itView( + "nodeChanged and treeChanged are both deferred to the same flush point", + ({ view }) => { + const log: string[] = []; + const root = view.root; + Tree.on(root, "treeChanged", () => log.push("treeChanged")); + Tree.on(root.items, "nodeChanged", () => log.push("items.nodeChanged")); + + view.runTransaction( + () => { + root.items.insertAtEnd("A"); + assert.deepEqual(log, [], "no events should fire during the transaction"); + root.items.insertAtEnd("B"); + assert.deepEqual(log, [], "no events should fire during the transaction"); + }, + { deferEvents: true }, + ); + + // Both events fire (each exactly once) after the transaction, not during it. + assert.deepEqual(log, ["treeChanged", "items.nodeChanged"]); + }, + { + initialContent: { + schema: BERoot, + initialTree: { items: [] }, + }, + }, + ); + }); }); describe("disposal", () => { diff --git a/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md b/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md index a127b3e23c8a..94dd3bce445e 100644 --- a/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md +++ b/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md @@ -1485,12 +1485,21 @@ export interface RunTransaction { readonly rollback: typeof rollback; } +// @alpha @input +export interface RunTransactionAsyncParams extends RunTransactionParams { +} + // @alpha @input export interface RunTransactionParams { readonly label?: unknown; readonly preconditions?: readonly TransactionConstraintAlpha[]; } +// @alpha @input +export interface RunTransactionSyncParams extends RunTransactionParams { + readonly deferEvents?: boolean; +} + // @public @sealed export interface SchemaCompatibilityStatus { readonly canInitialize: boolean; @@ -2173,10 +2182,10 @@ export interface TreeBranchAlpha extends TreeBranch, TreeContextAlpha { // (undocumented) fork(): TreeBranchAlpha; hasRootSchema(schema: TSchema): this is TreeViewAlpha; - runTransaction(transaction: () => TransactionCallbackStatus, params?: RunTransactionParams): TransactionResultExt; - runTransaction(transaction: () => VoidTransactionCallbackStatus | void, params?: RunTransactionParams): TransactionResult; - runTransactionAsync(transaction: () => Promise>, params?: RunTransactionParams): Promise>; - runTransactionAsync(transaction: () => Promise, params?: RunTransactionParams): Promise; + runTransaction(transaction: () => TransactionCallbackStatus, params?: RunTransactionSyncParams): TransactionResultExt; + runTransaction(transaction: () => VoidTransactionCallbackStatus | void, params?: RunTransactionSyncParams): TransactionResult; + runTransactionAsync(transaction: () => Promise>, params?: RunTransactionAsyncParams): Promise>; + runTransactionAsync(transaction: () => Promise, params?: RunTransactionAsyncParams): Promise; } // @alpha @sealed @@ -2211,10 +2220,10 @@ export enum TreeCompressionStrategy { // @alpha export interface TreeContextAlpha { isBranch(): this is TreeBranchAlpha; - runTransaction(transaction: () => WithValue, params?: RunTransactionParams): TransactionResultExt; - runTransaction(transaction: () => void, params?: RunTransactionParams): TransactionResult; - runTransactionAsync(transaction: () => Promise>, params?: RunTransactionParams): Promise>; - runTransactionAsync(transaction: () => Promise, params?: RunTransactionParams): Promise; + runTransaction(transaction: () => WithValue, params?: RunTransactionSyncParams): TransactionResultExt; + runTransaction(transaction: () => void, params?: RunTransactionSyncParams): TransactionResult; + runTransactionAsync(transaction: () => Promise>, params?: RunTransactionAsyncParams): Promise>; + runTransactionAsync(transaction: () => Promise, params?: RunTransactionAsyncParams): Promise; } // @beta @input