Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
18 changes: 15 additions & 3 deletions packages/dds/tree/src/shared-tree/treeCheckout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ import {
customFromCursorStored,
type CustomTreeValue,
type CustomTreeNode,
withBufferedTreeEvents,
} from "../simple-tree/index.js";
import {
Breakable,
Expand Down Expand Up @@ -859,9 +860,20 @@ export class TreeCheckout implements ITreeCheckout {
| void,
params?: RunTransactionParams,
): TransactionResultExt<TSuccessValue, TFailureValue> | TransactionResult {
this.mountTransaction(params, false);
const transactionCallbackStatus = transaction();
return this.unmountTransaction(transactionCallbackStatus, params);
const transactionCore = ():
| TransactionResultExt<TSuccessValue, TFailureValue>
| 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 === false,
})
: transactionCore();
}

public runTransactionAsync<TSuccessValue, TFailureValue>(
Expand Down
11 changes: 11 additions & 0 deletions packages/dds/tree/src/simple-tree/api/transactionTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -146,4 +147,14 @@ export interface RunTransactionParams {
* If there is a nested transaction, only the outermost transaction label will be used.
*/
readonly label?: unknown;

/**
* 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, no buffered events are emitted (the tree is unchanged).
Comment thread
Josmithr marked this conversation as resolved.
Outdated
*
* @defaultValue `false`
*/
readonly deferEvents?: boolean;
Comment thread
Josmithr marked this conversation as resolved.
}
81 changes: 69 additions & 12 deletions packages/dds/tree/src/simple-tree/core/treeNodeKernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,24 @@ type KernelEvents = Pick<AnchorEvents, (typeof kernelEvents)[number]>;
*/
let bufferTreeEvents: boolean = false;

/**
* Options for {@link withBufferedTreeEvents}.
*/
export interface WithBufferedTreeEventsOptions<TResult> {
/**
* 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.
*
Expand All @@ -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<TResult>(
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 think it might be cleaner to have the primary callback passed to withBufferedTreeEvents return true/false to determine discard behavior, rather than having this additional parameter. It would make sense since the whole point of this method is to buffer events, and the return value of the main callback would be controlling whether that happens or not.

The downside is that then you couldn't return the return value of the transaction directly, so you might have to add a few extra lines at the call site to capture the return value within the callback and have it outside. But the call site is already split up anyway to accommodate the current scheme:

const result = callback();
discard = options?.shouldDiscard?.(result) === true;
return result;

It's not like it's a super clean one liner, so I don't think it'd make it too much messier. And then this whole interface would get much simpler and self-describing. What do you think?

Copy link
Copy Markdown
Contributor Author

@Josmithr Josmithr May 7, 2026

Choose a reason for hiding this comment

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

Let me play around with it and see how it works out. I think I like it.

callback: () => TResult,
options?: WithBufferedTreeEventsOptions<TResult>,
): 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();
Comment thread
Josmithr marked this conversation as resolved.
Outdated
}
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;
}>();

/**
Expand All @@ -351,6 +378,14 @@ class KernelEventBuffer implements Listenable<KernelEvents> {
*/
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.discard.bind(this),
);

readonly #events = createEmitter<KernelEvents>();

#eventSource: Listenable<KernelEvents> & HasListeners<KernelEvents>;
Expand Down Expand Up @@ -526,6 +561,11 @@ class KernelEventBuffer implements Listenable<KernelEvents> {
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,
Expand All @@ -542,6 +582,22 @@ class KernelEventBuffer implements Listenable<KernelEvents> {
}
}
Comment thread
Josmithr marked this conversation as resolved.

/**
* 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 discard(): void {
this.#assertNotDisposed();
this.#childrenChangedBuffer.clear();
this.#fieldMarksBuffer.clear();
this.#invalidatedFieldMarkKeys.clear();
this.#subTreeChangedBuffer = false;
}

#assertNotDisposed(): void {
assert(!this.#disposed, 0xc51 /* Event handler disposed. */);
}
Expand All @@ -557,6 +613,7 @@ class KernelEventBuffer implements Listenable<KernelEvents> {
);

this.#disposeOnFlushListener();
this.#disposeOnDiscardListener();
for (const off of this.#disposeSourceListeners.values()) {
off();
}
Expand Down
Loading
Loading