diff --git a/packages/dds/tree/api-report/tree.alpha.api.md b/packages/dds/tree/api-report/tree.alpha.api.md index 3277c3e85e81..6541ece255b3 100644 --- a/packages/dds/tree/api-report/tree.alpha.api.md +++ b/packages/dds/tree/api-report/tree.alpha.api.md @@ -102,6 +102,18 @@ export interface ArrayNodeCustomizableSchemaUnsafe, SimpleArrayNodeSchema { } +// @beta @sealed +export type ArrayNodeDeltaOp = { + readonly type: "retain"; + readonly count: number; +} | { + readonly type: "insert"; + readonly count: number; +} | { + readonly type: "remove"; + readonly count: number; +}; + // @alpha @sealed @system export interface ArrayNodePojoEmulationSchema extends TreeNodeSchemaNonClass & WithType, Iterable>, ImplicitlyConstructable, T, undefined, TCustomMetadata>, SimpleArrayNodeSchema { } @@ -392,7 +404,7 @@ export namespace FluidSerializableAsTree { export type Data = JsonCompatible; const // @system _APIExtractorWorkaroundObjectBase: TreeNodeSchemaClass_2<"com.fluidframework.serializable.object", NodeKind_2.Record, TreeRecordNodeUnsafe_2 typeof FluidSerializableObject, () => typeof Array, LeafSchema_2<"string", string>, LeafSchema_2<"number", number>, LeafSchema_2<"boolean", boolean>, LeafSchema_2<"null", null>, LeafSchema_2<"handle", IFluidHandle>]> & WithType_2<"com.fluidframework.serializable.object", NodeKind_2.Record, unknown>, { - readonly [x: string]: string | number | IFluidHandle | System_Unsafe_2.InsertableTypedNodeUnsafe, LeafSchema_2<"boolean", boolean>> | FluidSerializableObject | Array | null; + readonly [x: string]: string | number | IFluidHandle | FluidSerializableObject | Array | System_Unsafe_2.InsertableTypedNodeUnsafe, LeafSchema_2<"boolean", boolean>> | null; }, false, readonly [() => typeof FluidSerializableObject, () => typeof Array, LeafSchema_2<"string", string>, LeafSchema_2<"number", number>, LeafSchema_2<"boolean", boolean>, LeafSchema_2<"null", null>, LeafSchema_2<"handle", IFluidHandle>], undefined, unknown>; // @sealed export class FluidSerializableObject extends _APIExtractorWorkaroundObjectBase { @@ -401,7 +413,7 @@ export namespace FluidSerializableAsTree { export type _RecursiveArrayWorkaroundJsonArray = FixRecursiveArraySchema; const // @system _APIExtractorWorkaroundArrayBase: TreeNodeSchemaClass_2<"com.fluidframework.serializable.array", NodeKind_2.Array, System_Unsafe_2.TreeArrayNodeUnsafe typeof FluidSerializableObject, () => typeof Array, LeafSchema_2<"string", string>, LeafSchema_2<"number", number>, LeafSchema_2<"boolean", boolean>, LeafSchema_2<"null", null>, LeafSchema_2<"handle", IFluidHandle>]> & WithType_2<"com.fluidframework.serializable.array", NodeKind_2.Array, unknown>, { - [Symbol.iterator](): Iterator | System_Unsafe_2.InsertableTypedNodeUnsafe, LeafSchema_2<"boolean", boolean>> | FluidSerializableObject | Array | null, any, undefined>; + [Symbol.iterator](): Iterator | FluidSerializableObject | Array | System_Unsafe_2.InsertableTypedNodeUnsafe, LeafSchema_2<"boolean", boolean>> | null, any, undefined>; }, false, readonly [() => typeof FluidSerializableObject, () => typeof Array, LeafSchema_2<"string", string>, LeafSchema_2<"number", number>, LeafSchema_2<"boolean", boolean>, LeafSchema_2<"null", null>, LeafSchema_2<"handle", IFluidHandle>], undefined>; // (undocumented) export type Tree = TreeNodeFromImplicitAllowedTypes; @@ -778,6 +790,7 @@ type NodeBuilderData> = // @beta @sealed export interface NodeChangedData { readonly changedProperties?: ReadonlySet ? string & keyof TInfo : string>; + readonly delta?: readonly ArrayNodeDeltaOp[]; } // @public @@ -1608,7 +1621,9 @@ export interface TreeChangeEvents { // @beta @sealed export interface TreeChangeEventsBeta extends TreeChangeEvents { - nodeChanged: (data: NodeChangedData & (TNode extends WithType ? Required, "changedProperties">> : unknown)) => void; + nodeChanged: (data: NodeChangedData & (TNode extends WithType ? Required, "changedProperties">> : TNode extends WithType ? { + readonly delta: readonly ArrayNodeDeltaOp[] | undefined; + } : unknown)) => void; } // @alpha diff --git a/packages/dds/tree/api-report/tree.beta.api.md b/packages/dds/tree/api-report/tree.beta.api.md index 33cd20fcbce3..e83ca78c45cb 100644 --- a/packages/dds/tree/api-report/tree.beta.api.md +++ b/packages/dds/tree/api-report/tree.beta.api.md @@ -86,6 +86,18 @@ type ApplyKindInput(view: TreeView): TreeViewBeta; @@ -211,7 +223,7 @@ export namespace FluidSerializableAsTree { export type Data = JsonCompatible; const // @system _APIExtractorWorkaroundObjectBase: TreeNodeSchemaClass_2<"com.fluidframework.serializable.object", NodeKind_2.Record, TreeRecordNodeUnsafe_2 typeof FluidSerializableObject, () => typeof Array, LeafSchema_2<"string", string>, LeafSchema_2<"number", number>, LeafSchema_2<"boolean", boolean>, LeafSchema_2<"null", null>, LeafSchema_2<"handle", IFluidHandle>]> & WithType_2<"com.fluidframework.serializable.object", NodeKind_2.Record, unknown>, { - readonly [x: string]: string | number | IFluidHandle | System_Unsafe_2.InsertableTypedNodeUnsafe, LeafSchema_2<"boolean", boolean>> | FluidSerializableObject | Array | null; + readonly [x: string]: string | number | IFluidHandle | FluidSerializableObject | Array | System_Unsafe_2.InsertableTypedNodeUnsafe, LeafSchema_2<"boolean", boolean>> | null; }, false, readonly [() => typeof FluidSerializableObject, () => typeof Array, LeafSchema_2<"string", string>, LeafSchema_2<"number", number>, LeafSchema_2<"boolean", boolean>, LeafSchema_2<"null", null>, LeafSchema_2<"handle", IFluidHandle>], undefined, unknown>; // @sealed export class FluidSerializableObject extends _APIExtractorWorkaroundObjectBase { @@ -220,7 +232,7 @@ export namespace FluidSerializableAsTree { export type _RecursiveArrayWorkaroundJsonArray = FixRecursiveArraySchema; const // @system _APIExtractorWorkaroundArrayBase: TreeNodeSchemaClass_2<"com.fluidframework.serializable.array", NodeKind_2.Array, System_Unsafe_2.TreeArrayNodeUnsafe typeof FluidSerializableObject, () => typeof Array, LeafSchema_2<"string", string>, LeafSchema_2<"number", number>, LeafSchema_2<"boolean", boolean>, LeafSchema_2<"null", null>, LeafSchema_2<"handle", IFluidHandle>]> & WithType_2<"com.fluidframework.serializable.array", NodeKind_2.Array, unknown>, { - [Symbol.iterator](): Iterator | System_Unsafe_2.InsertableTypedNodeUnsafe, LeafSchema_2<"boolean", boolean>> | FluidSerializableObject | Array | null, any, undefined>; + [Symbol.iterator](): Iterator | FluidSerializableObject | Array | System_Unsafe_2.InsertableTypedNodeUnsafe, LeafSchema_2<"boolean", boolean>> | null, any, undefined>; }, false, readonly [() => typeof FluidSerializableObject, () => typeof Array, LeafSchema_2<"string", string>, LeafSchema_2<"number", number>, LeafSchema_2<"boolean", boolean>, LeafSchema_2<"null", null>, LeafSchema_2<"handle", IFluidHandle>], undefined>; // (undocumented) export type Tree = TreeNodeFromImplicitAllowedTypes; @@ -373,6 +385,7 @@ type NodeBuilderData> = // @beta @sealed export interface NodeChangedData { readonly changedProperties?: ReadonlySet ? string & keyof TInfo : string>; + readonly delta?: readonly ArrayNodeDeltaOp[]; } // @public @@ -887,7 +900,9 @@ export interface TreeChangeEvents { // @beta @sealed export interface TreeChangeEventsBeta extends TreeChangeEvents { - nodeChanged: (data: NodeChangedData & (TNode extends WithType ? Required, "changedProperties">> : unknown)) => void; + nodeChanged: (data: NodeChangedData & (TNode extends WithType ? Required, "changedProperties">> : TNode extends WithType ? { + readonly delta: readonly ArrayNodeDeltaOp[] | undefined; + } : unknown)) => void; } // @beta @input diff --git a/packages/dds/tree/api-report/tree.legacy.beta.api.md b/packages/dds/tree/api-report/tree.legacy.beta.api.md index d35ccdef6418..35db76fa7ef1 100644 --- a/packages/dds/tree/api-report/tree.legacy.beta.api.md +++ b/packages/dds/tree/api-report/tree.legacy.beta.api.md @@ -86,6 +86,18 @@ type ApplyKindInput(view: TreeView): TreeViewBeta; @@ -214,7 +226,7 @@ export namespace FluidSerializableAsTree { export type Data = JsonCompatible; const // @system _APIExtractorWorkaroundObjectBase: TreeNodeSchemaClass_2<"com.fluidframework.serializable.object", NodeKind_2.Record, TreeRecordNodeUnsafe_2 typeof FluidSerializableObject, () => typeof Array, LeafSchema_2<"string", string>, LeafSchema_2<"number", number>, LeafSchema_2<"boolean", boolean>, LeafSchema_2<"null", null>, LeafSchema_2<"handle", IFluidHandle>]> & WithType_2<"com.fluidframework.serializable.object", NodeKind_2.Record, unknown>, { - readonly [x: string]: string | number | IFluidHandle | System_Unsafe_2.InsertableTypedNodeUnsafe, LeafSchema_2<"boolean", boolean>> | FluidSerializableObject | Array | null; + readonly [x: string]: string | number | IFluidHandle | FluidSerializableObject | Array | System_Unsafe_2.InsertableTypedNodeUnsafe, LeafSchema_2<"boolean", boolean>> | null; }, false, readonly [() => typeof FluidSerializableObject, () => typeof Array, LeafSchema_2<"string", string>, LeafSchema_2<"number", number>, LeafSchema_2<"boolean", boolean>, LeafSchema_2<"null", null>, LeafSchema_2<"handle", IFluidHandle>], undefined, unknown>; // @sealed export class FluidSerializableObject extends _APIExtractorWorkaroundObjectBase { @@ -223,7 +235,7 @@ export namespace FluidSerializableAsTree { export type _RecursiveArrayWorkaroundJsonArray = FixRecursiveArraySchema; const // @system _APIExtractorWorkaroundArrayBase: TreeNodeSchemaClass_2<"com.fluidframework.serializable.array", NodeKind_2.Array, System_Unsafe_2.TreeArrayNodeUnsafe typeof FluidSerializableObject, () => typeof Array, LeafSchema_2<"string", string>, LeafSchema_2<"number", number>, LeafSchema_2<"boolean", boolean>, LeafSchema_2<"null", null>, LeafSchema_2<"handle", IFluidHandle>]> & WithType_2<"com.fluidframework.serializable.array", NodeKind_2.Array, unknown>, { - [Symbol.iterator](): Iterator | System_Unsafe_2.InsertableTypedNodeUnsafe, LeafSchema_2<"boolean", boolean>> | FluidSerializableObject | Array | null, any, undefined>; + [Symbol.iterator](): Iterator | FluidSerializableObject | Array | System_Unsafe_2.InsertableTypedNodeUnsafe, LeafSchema_2<"boolean", boolean>> | null, any, undefined>; }, false, readonly [() => typeof FluidSerializableObject, () => typeof Array, LeafSchema_2<"string", string>, LeafSchema_2<"number", number>, LeafSchema_2<"boolean", boolean>, LeafSchema_2<"null", null>, LeafSchema_2<"handle", IFluidHandle>], undefined>; // (undocumented) export type Tree = TreeNodeFromImplicitAllowedTypes; @@ -376,6 +388,7 @@ type NodeBuilderData> = // @beta @sealed export interface NodeChangedData { readonly changedProperties?: ReadonlySet ? string & keyof TInfo : string>; + readonly delta?: readonly ArrayNodeDeltaOp[]; } // @public @@ -899,7 +912,9 @@ export interface TreeChangeEvents { // @beta @sealed export interface TreeChangeEventsBeta extends TreeChangeEvents { - nodeChanged: (data: NodeChangedData & (TNode extends WithType ? Required, "changedProperties">> : unknown)) => void; + nodeChanged: (data: NodeChangedData & (TNode extends WithType ? Required, "changedProperties">> : TNode extends WithType ? { + readonly delta: readonly ArrayNodeDeltaOp[] | undefined; + } : unknown)) => void; } // @beta @input diff --git a/packages/dds/tree/src/core/tree/anchorSet.ts b/packages/dds/tree/src/core/tree/anchorSet.ts index 295baf4bebdc..784ee8989d4a 100644 --- a/packages/dds/tree/src/core/tree/anchorSet.ts +++ b/packages/dds/tree/src/core/tree/anchorSet.ts @@ -128,7 +128,17 @@ export interface AnchorEvents { * * Compare to {@link AnchorEvents.childrenChanged} which is emitted in the middle of the batch/delta-visit. */ - childrenChangedAfterBatch(arg: { changedFields: ReadonlySet }): void; + childrenChangedAfterBatch(arg: { + changedFields: ReadonlySet; + /** + * The sequential delta marks for each changed field, keyed by field key. + * @remarks + * A field is absent from this map when no mark data was captured for it + * (e.g. for unhydrated nodes, or when marks were invalidated by multiple + * sequential batches touching the same field before the buffer was flushed). + */ + fieldMarks: ReadonlyMap; + }): void; /** * Emitted in the middle of applying a batch of changes (i.e. during a delta a visit), if something in the subtree @@ -726,6 +736,12 @@ export class AnchorSet implements AnchorLocator { */ bufferedEvents: [] as BufferedEvent[], + /** + * Field marks delivered via {@link DeltaVisitor.fieldMarks}, keyed by (PathNode, FieldKey). + * Populated during the first (detach) pass and consumed during "free" when emitting events. + */ + storedFieldMarks: new Map>(), + /** * 'currentDepth' and 'depthThresholdForSubtreeChanged' serve to keep track of when do we need to emit * subtreeChangedAfterBatch events. @@ -764,15 +780,31 @@ export class AnchorSet implements AnchorLocator { } this.anchorSet.activeVisitor = undefined; - // Aggregate changedFields by node. + // Aggregate changedFields and fieldMarks by node. const eventsByNode: Map> = new Map(); - for (const { node, event, changedField } of this.bufferedEvents) { + const marksByNode: Map> = new Map(); + for (const { node, event, changedField, fieldMarks } of this.bufferedEvents) { if (event === "childrenChangedAfterBatch") { - const keys = getOrCreate(eventsByNode, node, () => new Set()); - keys.add( + const resolvedField: FieldKey = changedField ?? - fail(0xb57 /* childrenChangedAfterBatch events should have a changedField */), - ); + fail(0xb57 /* childrenChangedAfterBatch events should have a changedField */); + const keys = getOrCreate(eventsByNode, node, () => new Set()); + keys.add(resolvedField); + if (fieldMarks !== undefined) { + const nodeMarks = getOrCreate(marksByNode, node, () => new Map()); + // First-wins is safe here because `fieldMarks` is called at most once per + // field per delta visit (the hook fires during the detach pass only). + // Within a single delta a field therefore produces at most one marks entry, + // so there is never a legitimate second entry to prefer over the first. + // If the same field is touched by two separate deltas before the buffer is + // flushed (e.g. because a schema change forced a second delta in the same + // batch), `KernelEventBuffer` detects the collision and removes the entry + // for that field entirely, so the consumer receives `undefined` rather than + // stale marks. + if (!nodeMarks.has(resolvedField)) { + nodeMarks.set(resolvedField, fieldMarks); + } + } } } @@ -787,7 +819,9 @@ export class AnchorSet implements AnchorLocator { const changedFields = eventsByNode.get(node) ?? fail(0xaeb /* childrenChangedAfterBatch events should have changedFields */); - node.events.emit(event, { changedFields }); + const fieldMarks: ReadonlyMap = + marksByNode.get(node) ?? new Map(); + node.events.emit(event, { changedFields, fieldMarks }); } else { node.events.emit(event); } @@ -800,17 +834,20 @@ export class AnchorSet implements AnchorLocator { ); }, notifyChildrenChanged(): void { + const parentField = this.parentField; this.maybeWithNode( (p) => { assert( - this.parentField !== undefined, + parentField !== undefined, 0xa24 /* Must be in a field to modify its contents */, ); p.events.emit("childrenChanged", p); + const fieldMarks = this.storedFieldMarks.get(p)?.get(parentField); this.bufferedEvents.push({ node: p, event: "childrenChangedAfterBatch", - changedField: this.parentField, + changedField: parentField, + fieldMarks, }); }, () => {}, @@ -934,6 +971,14 @@ export class AnchorSet implements AnchorLocator { exitField(key: FieldKey): void { this.parentField = undefined; }, + fieldMarks(key: FieldKey, marks: readonly Delta.Mark[]): void { + if (this.parent !== undefined) { + const interned = this.anchorSet.internalizePath(this.parent); + if (interned instanceof PathNode) { + getOrCreate(this.storedFieldMarks, interned, () => new Map()).set(key, marks); + } + } + }, }; this.#events.emit("treeChanging", this); this.activeVisitor = visitor; @@ -1241,4 +1286,9 @@ interface BufferedEvent { * Some events, such as afterDestroy, do not involve a key, and thus leave this undefined. */ changedField?: FieldKey; + /** + * The sequential delta marks for the impacted field, if available. + * Populated from {@link DeltaVisitor.fieldMarks} during the first (detach) pass. + */ + fieldMarks?: readonly Delta.Mark[]; } diff --git a/packages/dds/tree/src/core/tree/visitDelta.ts b/packages/dds/tree/src/core/tree/visitDelta.ts index 4b11e2213935..b0ef245033ec 100644 --- a/packages/dds/tree/src/core/tree/visitDelta.ts +++ b/packages/dds/tree/src/core/tree/visitDelta.ts @@ -75,6 +75,7 @@ export function visitDelta( } const detachConfig: PassConfig = { func: detachPass, + isFirstPass: true, latestRevision, refreshers, detachedFieldIndex, @@ -98,6 +99,7 @@ export function visitDelta( ); const attachConfig: PassConfig = { func: attachPass, + isFirstPass: false, latestRevision, refreshers, detachedFieldIndex, @@ -318,11 +320,29 @@ export interface DeltaVisitor { * @remarks This should only be called when the "current location" is a Field. */ exitField(key: FieldKey): void; + + /** + * Optional hook for obtaining the complete ordered change description for a field, suitable + * for producing positional deltas for external representations (e.g. a text editor or virtual + * list) without needing to diff the old and new states. + * @remarks + * Called once per field per delta, during the first (detach) pass only, after `enterField` but + * before any `attach`, `detach`, or `enterNode` calls for the field. Firing only during the + * detach pass ensures marks are always relative to the original array positions rather than an + * intermediate partially-transformed state. + */ + fieldMarks?(key: FieldKey, marks: readonly Delta.Mark[]): void; } interface PassConfig { readonly func: Pass; + /** + * True for the first (detach) pass, false for the second (attach) pass. + * Used to ensure `fieldMarks` is called exactly once per field per delta visit. + */ + readonly isFirstPass: boolean; + /** * The latest revision tag associated with the given delta. This is used to keep track * of when repair data should be garbage collected. @@ -372,6 +392,9 @@ function visitFieldMarks( if (fields !== undefined) { for (const [key, field] of fields) { visitor.enterField(key); + if (config.isFirstPass) { + visitor.fieldMarks?.(key, field.marks); + } config.func(field, visitor, config); visitor.exitField(key); } diff --git a/packages/dds/tree/src/core/tree/visitorUtils.ts b/packages/dds/tree/src/core/tree/visitorUtils.ts index ff19e7711d6e..c99aa95109ca 100644 --- a/packages/dds/tree/src/core/tree/visitorUtils.ts +++ b/packages/dds/tree/src/core/tree/visitorUtils.ts @@ -149,6 +149,11 @@ export function combineVisitors(visitors: readonly CombinableVisitor[]): Combine v.exitField(...args); } }, + fieldMarks: (key, marks) => { + for (const v of allVisitors) { + v.fieldMarks?.(key, marks); + } + }, }; } diff --git a/packages/dds/tree/src/index.ts b/packages/dds/tree/src/index.ts index b4533bb891a4..a14637410972 100644 --- a/packages/dds/tree/src/index.ts +++ b/packages/dds/tree/src/index.ts @@ -129,6 +129,7 @@ export { normalizeFieldSchema, type InternalTreeNode, type WithType, + type ArrayNodeDeltaOp, type NodeChangedData, type SchemaUpgrade, contentSchemaSymbol, diff --git a/packages/dds/tree/src/simple-tree/api/index.ts b/packages/dds/tree/src/simple-tree/api/index.ts index 49ae1ec7d55a..1968ac5ab26c 100644 --- a/packages/dds/tree/src/simple-tree/api/index.ts +++ b/packages/dds/tree/src/simple-tree/api/index.ts @@ -137,6 +137,7 @@ export { borrowCursorFromTreeNodeOrValue, exportConcise, importConcise, + type ArrayNodeDeltaOp, type NodeChangedData, TreeBeta, type TreeChangeEventsBeta, diff --git a/packages/dds/tree/src/simple-tree/api/treeBeta.ts b/packages/dds/tree/src/simple-tree/api/treeBeta.ts index 1240c4d1c7ab..5e60113f2b54 100644 --- a/packages/dds/tree/src/simple-tree/api/treeBeta.ts +++ b/packages/dds/tree/src/simple-tree/api/treeBeta.ts @@ -38,7 +38,8 @@ import { conciseFromCursor, type ConciseTree } from "./conciseTree.js"; import { createFromCursor } from "./create.js"; import type { TreeEncodingOptions } from "./customTree.js"; import type { TreeChangeEvents } from "./treeChangeEvents.js"; -import { treeNodeApi } from "./treeNodeApi.js"; +import { treeNodeApi, type ArrayNodeDeltaOp } from "./treeNodeApi.js"; +export type { ArrayNodeDeltaOp } from "./treeNodeApi.js"; import { cursorFromVerbose } from "./verboseTree.js"; // Tests for this file are grouped with those for treeNodeApi.ts as that is where this functionality will eventually land, @@ -54,7 +55,7 @@ export interface NodeChangedData { * @remarks * This only includes changes to the node itself (which would trigger {@link TreeChangeEvents.nodeChanged}). * - * Set to `undefined` when the {@link NodeKind} does not support this feature (currently just ArrayNodes). + * Not present when the {@link NodeKind} does not support this feature (currently just ArrayNodes). * * When defined, the set should never be empty, since `nodeChanged` will only be triggered when there is a change, and for the supported node types, the only things that can change are properties. */ @@ -64,6 +65,21 @@ export interface NodeChangedData { ? string & keyof TInfo : string >; + + /** + * When the node changed is an array node, the sequential operations describing what changed. + * @remarks + * Not present for object, map, or record nodes — use {@link NodeChangedData.changedProperties} instead. + * + * When present, the value may still be `undefined` in two cases: + * - The array node is {@link Unhydrated} — unhydrated nodes are not visited by the delta + * pipeline, so no field marks are available. + * - The array was modified across multiple batches within a single flush (e.g. due to an + * interleaved schema change) and the marks from those batches could not be composed. + * + * See {@link ArrayNodeDeltaOp} for op semantics. + */ + readonly delta?: readonly ArrayNodeDeltaOp[]; } /** @@ -112,7 +128,15 @@ export interface TreeChangeEventsBeta // Make the properties of object, map, and record nodes required: (TNode extends WithType ? Required, "changedProperties">> - : unknown), + : // For array nodes, guarantee `delta` is always present in the data object. + // The value may still be `undefined` when marks could not be composed across + // multiple batches (e.g. due to an interleaved schema change). + // TODO: Once the eventing stack is rewritten, `delta` will always be defined. + // Simplify back to `Required, "delta">>` and + // remove `| undefined` from `NodeChangedData.delta`. + TNode extends WithType + ? { readonly delta: readonly ArrayNodeDeltaOp[] | undefined } + : unknown), ) => void; } diff --git a/packages/dds/tree/src/simple-tree/api/treeNodeApi.ts b/packages/dds/tree/src/simple-tree/api/treeNodeApi.ts index bdeed38c5dda..469d0ba82bf9 100644 --- a/packages/dds/tree/src/simple-tree/api/treeNodeApi.ts +++ b/packages/dds/tree/src/simple-tree/api/treeNodeApi.ts @@ -8,7 +8,7 @@ import { assert, oob, fail, unreachableCase } from "@fluidframework/core-utils/i import { isFluidHandle } from "@fluidframework/runtime-utils/internal"; import { UsageError } from "@fluidframework/telemetry-utils/internal"; -import { EmptyKey, rootFieldKey } from "../../core/index.js"; +import { EmptyKey, rootFieldKey, type DeltaMark } from "../../core/index.js"; import { type TreeStatus, isTreeValue, FieldKinds } from "../../feature-libraries/index.js"; import { extractFromOpaque } from "../../util/index.js"; import { @@ -39,6 +39,32 @@ import { isArrayNodeSchema, isObjectNodeSchema } from "../node-kinds/index.js"; import type { TreeChangeEvents } from "./treeChangeEvents.js"; +/** + * A single operation in an array node change delta, used to efficiently sync an external + * representation of an array (e.g. a text editor or virtual list) with tree changes, without + * needing to snapshot the old state or diff the entire array. Each op describes a contiguous run + * of positions in the array before the change; for inserts, read the new element values from the + * current tree at those positions. + * + * @remarks + * - `"retain"` — elements that were not added or removed (may have nested changes). + * - `"insert"` — elements added to the array. + * - `"remove"` — elements removed from the array. + * + * Moves are represented as remove + insert. + * There is no dedicated `"move"` op. When an element is moved within the same array it appears + * as a `"remove"` at the source position followed by an `"insert"` at the destination position. + * When an element is moved across two different arrays, the source array's delta contains a + * `"remove"` and the destination array's delta contains an `"insert"` — they cannot be + * correlated without additional bookkeeping on the caller's side. + * + * @sealed @beta + */ +export type ArrayNodeDeltaOp = + | { readonly type: "retain"; readonly count: number } + | { readonly type: "insert"; readonly count: number } + | { readonly type: "remove"; readonly count: number }; + /** * Provides various functions for analyzing {@link TreeNode}s. * @@ -192,8 +218,17 @@ export const treeNodeApi: TreeNodeApi = { listener({ changedProperties }); }); } else if (isArrayNodeSchema(nodeSchema)) { - return kernel.events.on("childrenChangedAfterBatch", () => { - listener({ changedProperties: undefined }); + return kernel.events.on("childrenChangedAfterBatch", ({ fieldMarks }) => { + const marks = fieldMarks.get(EmptyKey); + // `marks` is undefined when the field was modified across multiple batches + // within a single flush (e.g. due to an interleaved schema change) and the + // marks could not be composed. Emit `undefined` so callers know the delta is + // unavailable rather than receiving stale marks from only the first batch. + // TODO: Once the eventing stack is rewritten to walk the composed delta at + // flush time, `marks` will always be defined. Remove the `undefined` fallback + // and simplify to: `const delta = deltaMarksToArrayOps(marks);` + const delta = marks === undefined ? undefined : deltaMarksToArrayOps(marks); + listener({ delta }); }); } else { return kernel.events.on("childrenChangedAfterBatch", ({ changedFields }) => { @@ -234,6 +269,43 @@ export const treeNodeApi: TreeNodeApi = { }, }; +/** + * Converts an array of internal {@link DeltaMark}s for a sequence field into sequential + * array delta ops suitable for inclusion in {@link NodeChangedData.delta}. + * + * Each mark in the delta describes a contiguous run of positions in the original array: + * - A mark with only `count` (no attach/detach) → `"retain"` (elements unchanged at this level) + * - A mark with only `attach` → `"insert"` (new elements added) + * - A mark with only `detach` → `"remove"` (elements removed) + * - A mark with both `attach` and `detach` → `"remove"` + `"insert"` (replacement) + * + * @privateRemarks + * The `attach && detach` branch is defensive: the sequence-field encoder does not currently emit + * marks with both set for array (EmptyKey) fields, so this path is unreachable in practice today. + * It is retained in case future encoder changes produce such marks. + */ +function deltaMarksToArrayOps(marks: readonly DeltaMark[]): ArrayNodeDeltaOp[] { + const ops: ArrayNodeDeltaOp[] = []; + for (const mark of marks) { + if (mark.attach !== undefined && mark.detach !== undefined) { + // Replacement: content removed and new content attached in the same position. + // The `Delta.Mark` format allows both to be set simultaneously (e.g. when a slot's + // content is atomically swapped), even though the sequence-field encoder does not + // currently emit such marks for array (EmptyKey) fields. Handle it defensively. + ops.push({ type: "remove", count: mark.count }); + ops.push({ type: "insert", count: mark.count }); + } else if (mark.attach !== undefined) { + ops.push({ type: "insert", count: mark.count }); + } else if (mark.detach === undefined) { + // Neither attach nor detach: elements retained (may have nested changes in mark.fields). + ops.push({ type: "retain", count: mark.count }); + } else { + ops.push({ type: "remove", count: mark.count }); + } + } + return ops; +} + /** * Returns a schema for a value if the value is a {@link TreeNode} or a {@link TreeLeafValue}. * Returns undefined for other values. diff --git a/packages/dds/tree/src/simple-tree/core/treeNodeKernel.ts b/packages/dds/tree/src/simple-tree/core/treeNodeKernel.ts index 27b2bfcd46c1..5834874e5239 100644 --- a/packages/dds/tree/src/simple-tree/core/treeNodeKernel.ts +++ b/packages/dds/tree/src/simple-tree/core/treeNodeKernel.ts @@ -17,6 +17,7 @@ import { anchorSlot, type AnchorEvents, type AnchorNode, + type DeltaMark, type FieldKey, type TreeValue, } from "../../core/index.js"; @@ -362,6 +363,20 @@ class KernelEventBuffer implements Listenable { */ readonly #childrenChangedBuffer: Set = new Set(); + /** + * Buffer of field marks accumulated since events were paused. + * Emitted alongside the buffered changed-fields set when flushed. + */ + readonly #fieldMarksBuffer: Map = new Map(); + + /** + * Fields whose marks have been permanently invalidated within the current buffer window due to + * two or more separate delta batches touching the same field. + * Once a key is in this set it must never be re-added to the marks buffer, even if + * a third (or later) batch arrives for that field. + */ + readonly #invalidatedFieldMarkKeys: Set = new Set(); + /** * Whether or not the subtree has changed since events were paused. * When events are flushed, a single {@link AnchorEvents.subTreeChanged} event will be emitted if and only @@ -399,8 +414,10 @@ class KernelEventBuffer implements Listenable { this.#eventSource = newSource; if (this.#events.hasListeners("childrenChangedAfterBatch")) { - const off = this.#eventSource.on("childrenChangedAfterBatch", ({ changedFields }) => - this.#emit("childrenChangedAfterBatch", { changedFields }), + const off = this.#eventSource.on( + "childrenChangedAfterBatch", + ({ changedFields, fieldMarks }) => + this.#emit("childrenChangedAfterBatch", { changedFields, fieldMarks }), ); this.#disposeSourceListeners.set("childrenChangedAfterBatch", off); } @@ -421,7 +438,10 @@ class KernelEventBuffer implements Listenable { 0xc4f /* Should not have a dispose function without listeners */, ); - const off = this.#eventSource.on(eventName, (args) => this.#emit(eventName, args)); + const off: Off = + eventName === "childrenChangedAfterBatch" + ? this.#eventSource.on(eventName, (args) => this.#emit(eventName, args)) + : this.#eventSource.on(eventName, () => this.#emit(eventName)); this.#disposeSourceListeners.set(eventName, off); } @@ -441,16 +461,21 @@ class KernelEventBuffer implements Listenable { } #emit( - eventName: keyof KernelEvents, - arg?: { - changedFields: ReadonlySet; - }, + ...args: + | [ + eventName: "childrenChangedAfterBatch", + arg: { + changedFields: ReadonlySet; + fieldMarks: ReadonlyMap; + }, + ] + | [eventName: "subtreeChangedAfterBatch"] ): void { this.#assertNotDisposed(); + const [eventName, arg] = args; switch (eventName) { case "childrenChangedAfterBatch": { - assert(arg !== undefined, 0xc50 /* childrenChangedAfterBatch should have arg */); - return this.#handleChildrenChangedAfterBatch(arg.changedFields); + return this.#handleChildrenChangedAfterBatch(arg.changedFields, arg.fieldMarks); } case "subtreeChangedAfterBatch": { return this.#handleSubtreeChangedAfterBatch(); @@ -461,13 +486,33 @@ class KernelEventBuffer implements Listenable { } } - #handleChildrenChangedAfterBatch(changedFields: ReadonlySet): void { + #handleChildrenChangedAfterBatch( + changedFields: ReadonlySet, + fieldMarks: ReadonlyMap, + ): void { if (bufferTreeEvents) { for (const fieldKey of changedFields) { this.#childrenChangedBuffer.add(fieldKey); } + for (const [key, marks] of fieldMarks) { + if (this.#invalidatedFieldMarkKeys.has(key)) { + // Already permanently invalidated by an earlier collision; ignore this batch too. + // TODO: Once the eventing stack is rewritten to walk the composed delta at flush + // time, this collision path will be unreachable and can be removed entirely. + continue; + } + if (this.#fieldMarksBuffer.has(key)) { + // A second batch of marks arrived for the same field before the buffer was flushed. + // We have no delta composition logic, so permanently invalidate this field so that + // any further batches are also discarded rather than incorrectly surfaced. + this.#fieldMarksBuffer.delete(key); + this.#invalidatedFieldMarkKeys.add(key); + } else { + this.#fieldMarksBuffer.set(key, marks); + } + } } else { - this.#events.emit("childrenChangedAfterBatch", { changedFields }); + this.#events.emit("childrenChangedAfterBatch", { changedFields, fieldMarks }); } } @@ -488,8 +533,11 @@ class KernelEventBuffer implements Listenable { 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) { @@ -519,6 +567,8 @@ class KernelEventBuffer implements Listenable { this.#disposeSourceListeners.clear(); this.#childrenChangedBuffer.clear(); + this.#fieldMarksBuffer.clear(); + this.#invalidatedFieldMarkKeys.clear(); this.#subTreeChangedBuffer = false; this.#disposed = true; diff --git a/packages/dds/tree/src/simple-tree/core/unhydratedFlexTree.ts b/packages/dds/tree/src/simple-tree/core/unhydratedFlexTree.ts index a02e213ca5c2..b46fbd32d281 100644 --- a/packages/dds/tree/src/simple-tree/core/unhydratedFlexTree.ts +++ b/packages/dds/tree/src/simple-tree/core/unhydratedFlexTree.ts @@ -271,7 +271,11 @@ export class UnhydratedFlexTreeNode } public emitChangedEvent(key: FieldKey): void { - this._events.emit("childrenChangedAfterBatch", { changedFields: new Set([key]) }); + this._events.emit("childrenChangedAfterBatch", { + changedFields: new Set([key]), + // Unhydrated nodes are not visited by the delta pipeline, so no field marks are available. + fieldMarks: new Map(), + }); // Also emit subtree changed event for this node and all ancestors. this.#emitSubtreeChangedEvents(); diff --git a/packages/dds/tree/src/simple-tree/index.ts b/packages/dds/tree/src/simple-tree/index.ts index 65cadbe26716..17e86f948a67 100644 --- a/packages/dds/tree/src/simple-tree/index.ts +++ b/packages/dds/tree/src/simple-tree/index.ts @@ -89,6 +89,7 @@ export { singletonSchema, treeNodeApi, type TreeNodeApi, + type ArrayNodeDeltaOp, type NodeChangedData, borrowCursorFromTreeNodeOrValue, exportConcise, diff --git a/packages/dds/tree/src/test/shared-tree/fuzz/arrayNodeDelta.fuzz.spec.ts b/packages/dds/tree/src/test/shared-tree/fuzz/arrayNodeDelta.fuzz.spec.ts new file mode 100644 index 000000000000..962697eebe2a --- /dev/null +++ b/packages/dds/tree/src/test/shared-tree/fuzz/arrayNodeDelta.fuzz.spec.ts @@ -0,0 +1,377 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "node:assert"; + +import { + createWeightedGenerator, + done, + takeAsync, + type AsyncGenerator, +} from "@fluid-private/stochastic-test-utils"; +import { + type Client, + type DDSFuzzModel, + type DDSFuzzSuiteOptions, + type DDSFuzzTestState, + createDDSFuzzSuite, +} from "@fluid-private/test-dds-utils"; + +import { + type ArrayNodeDeltaOp, + SchemaFactory, + TreeBeta, + TreeViewConfiguration, + type TreeView, +} from "../../../simple-tree/index.js"; +import { getOrCreate } from "../../../util/index.js"; +import { SharedTreeTestFactory } from "../../utils.js"; + +import { deterministicIdCompressorFactory, failureDirectory } from "./fuzzUtils.js"; + +const sf = new SchemaFactory("arrayDeltaFuzz"); +const NumArray = sf.array("NumArray", sf.number); +const Parent = sf.object("Parent", { arr1: NumArray, arr2: NumArray }); +const viewConfig = new TreeViewConfiguration({ schema: Parent }); + +type ParentNode = InstanceType; + +// One schematized view per SharedTree channel — SharedTree disallows multiple +// concurrent views on the same branch, so we cache rather than re-creating. +const viewCache = new WeakMap>(); + +function getRoot(client: Client): ParentNode { + let view = viewCache.get(client.channel); + if (view === undefined) { + view = client.channel.viewWith(viewConfig); + viewCache.set(client.channel, view); + } + const root = view.root; + assert(root !== undefined, "Tree root must be initialized before accessing it"); + return root; +} + +type ArrayField = "arr1" | "arr2"; + +/** + * Custom op type for this suite rather than reusing the types in `operationTypes.ts`. + * + * The shared types are designed for the generic `FuzzNode` schema: fields are addressed via + * `DownPath`, inserted content is `GeneratedFuzzNode[]`, and ranges use `NodeRange`. + * This suite uses a fixed two-field schema of plain number arrays, so field identity and + * inserted values can be expressed directly without path navigation or content descriptors. + */ +type Op = + | { + readonly type: "insert"; + readonly field: ArrayField; + readonly index: number; + readonly value: number; + } + | { readonly type: "remove"; readonly field: ArrayField; readonly index: number } + | { + readonly type: "intraMove"; + readonly field: ArrayField; + readonly dst: number; + readonly src: number; + } + | { + readonly type: "crossMove"; + readonly dstField: ArrayField; + readonly srcIndex: number; + readonly dstIndex: number; + } + | { readonly type: "synchronize" }; + +type FuzzState = DDSFuzzTestState; + +interface OpWeights { + insert: number; + remove: number; + intraMove: number; + crossMove: number; +} + +const defaultOpWeights: OpWeights = { + insert: 2, + remove: 2, + intraMove: 3, + crossMove: 3, +}; + +/** + * Advances a shadow copy of an array by one step using an {@link ArrayNodeDeltaOp} sequence. + * `retain N` copies N elements from `shadow`; `remove N` discards N; `insert N` pulls N elements + * from `after` at the current output position (insert ops carry only a count, so inserted values + * must be read from the live post-op tree). Trailing retains are implicit. + */ +function applyDeltaToArray( + shadow: readonly number[], + after: readonly number[], + delta: readonly ArrayNodeDeltaOp[], +): number[] { + const result: number[] = []; + let srcIdx = 0; + + for (const op of delta) { + switch (op.type) { + case "retain": { + result.push(...shadow.slice(srcIdx, srcIdx + op.count)); + srcIdx += op.count; + break; + } + case "remove": { + srcIdx += op.count; + break; + } + case "insert": { + // Insert ops carry only a count; the inserted values must be read from the + // post-op tree. We index `after` by the current output position rather + // than the source position (`srcIdx`) because the new elements land at the + // output cursor, not at any position in the original array. + const outIdx = result.length; + result.push(...after.slice(outIdx, outIdx + op.count)); + break; + } + default: { + const _: never = op; + throw new Error(`Unexpected op type: ${JSON.stringify(_)}`); + } + } + } + result.push(...shadow.slice(srcIdx)); // implicit trailing retain + return result; +} + +/** + * Shadow copy of both array fields for one client. Updated continuously via + * `nodeChanged` delta events so that after every operation (local or remote) + * `shadow.arr1` and `shadow.arr2` should equal the live tree arrays. + */ +interface ClientShadow { + arr1: number[]; + arr2: number[]; +} + +// One shadow per SharedTree channel, initialised lazily on first access. +const shadowCache = new WeakMap(); + +/** + * Returns the shadow for `client`, creating and subscribing it if this is the first access. + * Must be called before any operations that could fire `nodeChanged` on this client. + */ +function getShadow(client: Client): ClientShadow { + return getOrCreate(shadowCache, client.channel, () => { + const root = getRoot(client); + const shadow: ClientShadow = { arr1: [...root.arr1], arr2: [...root.arr2] }; + TreeBeta.on(root.arr1, "nodeChanged", ({ delta }) => { + assert( + delta !== undefined, + "delta should always be defined without withBufferedTreeEvents", + ); + shadow.arr1 = applyDeltaToArray(shadow.arr1, [...root.arr1], delta); + }); + TreeBeta.on(root.arr2, "nodeChanged", ({ delta }) => { + assert( + delta !== undefined, + "delta should always be defined without withBufferedTreeEvents", + ); + shadow.arr2 = applyDeltaToArray(shadow.arr2, [...root.arr2], delta); + }); + return shadow; + }); +} + +/** Asserts that the shadow for `client` matches the live tree state. */ +function verifyShadow(client: Client, label: string): void { + const shadow = getShadow(client); + const root = getRoot(client); + assert.deepEqual(shadow.arr1, [...root.arr1], `${label} arr1 shadow diverged`); + assert.deepEqual(shadow.arr2, [...root.arr2], `${label} arr2 shadow diverged`); +} + +const fields = ["arr1", "arr2"] as const; + +function makeOpGenerator(weights: OpWeights = defaultOpWeights) { + return createWeightedGenerator([ + // insert: insert a random number at a random index in either field. + [ + (state): Op => { + const root = getRoot(state.client); + const field = state.random.bool() ? "arr1" : "arr2"; + return { + type: "insert", + field, + index: state.random.integer(0, root[field].length), + value: state.random.integer(0, 99), + }; + }, + weights.insert, + ], + // remove: remove a random element from a non-empty field. + [ + (state): Op => { + const root = getRoot(state.client); + const candidates = fields.filter((f) => root[f].length > 0); + const field = state.random.pick(candidates); + return { + type: "remove", + field, + index: state.random.integer(0, root[field].length - 1), + }; + }, + weights.remove, + (state) => { + const root = getRoot(state.client); + return root.arr1.length > 0 || root.arr2.length > 0; + }, + ], + // intraMove: move a single element within one field. + // Maps a choice in [0, len-2] to a destination gap that skips the two no-op + // gaps adjacent to `src` (gaps src and src+1 leave the element in place). + [ + (state): Op => { + const root = getRoot(state.client); + const candidates = fields.filter((f) => root[f].length > 1); + const field = state.random.pick(candidates); + const len = root[field].length; + const src = state.random.integer(0, len - 1); + const c = state.random.integer(0, len - 2); + const dst = c >= src ? c + 2 : c; + return { type: "intraMove", field, dst, src }; + }, + weights.intraMove, + (state) => { + const root = getRoot(state.client); + return root.arr1.length > 1 || root.arr2.length > 1; + }, + ], + // crossMove: move a single element from one field to a random position in the other. + // Picks the source as a non-empty field so the move is always valid. + // dstIndex is in [0, dstArr.length] — covers mid-array as well as end-of-array destinations. + [ + (state): Op => { + const root = getRoot(state.client); + const candidates = fields.filter((f) => root[f].length > 0); + const srcField = state.random.pick(candidates); + const dstField: ArrayField = srcField === "arr1" ? "arr2" : "arr1"; + return { + type: "crossMove", + dstField, + srcIndex: state.random.integer(0, root[srcField].length - 1), + dstIndex: state.random.integer(0, root[dstField].length), + }; + }, + weights.crossMove, + (state) => { + const root = getRoot(state.client); + return root.arr1.length > 0 || root.arr2.length > 0; + }, + ], + ]); +} + +const opGenerator = makeOpGenerator(); + +async function generateOp(state: FuzzState): Promise { + const result = opGenerator(state); + assert(result !== done, "op generator unexpectedly exhausted"); + return result; +} + +const reducer = (state: FuzzState, op: Op): void => { + if (op.type === "synchronize") { + // Ensure all clients have shadows subscribed before remote events fire. + for (const client of state.clients) { + getShadow(client); + } + state.containerRuntimeFactory.processAllMessages(); + // nodeChanged handlers have already advanced every shadow; just verify. + for (const [i, client] of state.clients.entries()) { + verifyShadow(client, `sync client[${i}]`); + } + return; + } + + // Ensure this client's shadow is subscribed before applying the local edit. + getShadow(state.client); + const root = getRoot(state.client); + + switch (op.type) { + case "insert": { + root[op.field].insertAt(op.index, op.value); + break; + } + case "remove": { + const arr = root[op.field]; + if (op.index < arr.length) { + arr.removeAt(op.index); + } + break; + } + case "intraMove": { + const arr = root[op.field]; + if (op.src < arr.length && op.dst <= arr.length) { + arr.moveToIndex(op.dst, op.src); + } + break; + } + case "crossMove": { + const dstArr = root[op.dstField]; + const srcField: ArrayField = op.dstField === "arr1" ? "arr2" : "arr1"; + const srcArr = root[srcField]; + if (op.srcIndex < srcArr.length && op.dstIndex <= dstArr.length) { + dstArr.moveToIndex(op.dstIndex, op.srcIndex, srcArr); + } + break; + } + default: { + const _: never = op; + throw new Error(`Unexpected op type: ${JSON.stringify(_)}`); + } + } + + verifyShadow(state.client, JSON.stringify(op)); +}; + +describe("Fuzz - ArrayNodeDelta: delta events keep per-client shadow consistent after rebase", () => { + const runsPerBatch = 10; + const opsPerRun = 30; + + const model: DDSFuzzModel = { + workloadName: "arrayNodeDelta", + factory: new SharedTreeTestFactory((tree) => { + const view = tree.viewWith(viewConfig); + view.initialize({ arr1: [1, 2, 3, 4, 5], arr2: [10, 20, 30, 40, 50] }); + view.dispose(); + }), + generatorFactory: (): AsyncGenerator => takeAsync(opsPerRun, generateOp), + reducer, + validateConsistency: (clientA, clientB) => { + const rootA = getRoot(clientA); + const rootB = getRoot(clientB); + assert.deepEqual([...rootA.arr1], [...rootB.arr1], "arr1 diverged between clients"); + assert.deepEqual([...rootA.arr2], [...rootB.arr2], "arr2 diverged between clients"); + }, + }; + + const options: Partial = { + numberOfClients: 2, + clientJoinOptions: { + maxNumberOfClients: 4, + clientAddProbability: 0.1, + }, + defaultTestCount: runsPerBatch, + saveFailures: { directory: failureDirectory }, + detachedStartOptions: { + numOpsBeforeAttach: 5, + attachingBeforeRehydrateDisable: true, + }, + reconnectProbability: 0.1, + idCompressorFactory: deterministicIdCompressorFactory(0xdeadbeef), + }; + + createDDSFuzzSuite(model, options); +}); diff --git a/packages/dds/tree/src/test/simple-tree/api/treeNodeApi.spec.ts b/packages/dds/tree/src/test/simple-tree/api/treeNodeApi.spec.ts index 852b9b6b2d6e..fd6fa6316529 100644 --- a/packages/dds/tree/src/test/simple-tree/api/treeNodeApi.spec.ts +++ b/packages/dds/tree/src/test/simple-tree/api/treeNodeApi.spec.ts @@ -46,6 +46,7 @@ import { // eslint-disable-next-line import-x/no-internal-modules import { getUnhydratedContext } from "../../../simple-tree/createContext.js"; import { + type ArrayNodeDeltaOp, type InsertableField, type InsertableTreeNodeFromImplicitAllowedTypes, isTreeNode, @@ -2714,6 +2715,132 @@ describe("treeNodeApi", () => { assert.deepEqual(eventLog, [undefined]); }); + describe(`'nodeChanged' delta payload for array operations`, () => { + // These tests verify the concrete ArrayNodeDeltaOp values emitted for specific + // array mutations. The delta follows Quill-style semantics: + // retain – elements that remain in place (leading unchanged elements) + // insert – elements added to the array + // remove – elements removed from the array + // Trailing unchanged elements are NOT represented by a trailing retain op. + const sb = new SchemaFactory("nodeChanged-delta-content"); + class TestArray extends sb.array("TestArray", [sb.number]) {} + + it(`insertAtEnd emits a leading retain followed by an insert`, () => { + const view = getView(new TreeViewConfiguration({ schema: TestArray })); + view.initialize([1, 2, 3]); + const root = view.root; + + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(root, "nodeChanged", ({ delta }) => deltas.push(delta)); + + root.insertAtEnd(4); + + assert.deepEqual(deltas, [ + [ + { type: "retain", count: 3 }, + { type: "insert", count: 1 }, + ], + ]); + }); + + it(`insertAt(0) emits only an insert op (no leading retain)`, () => { + const view = getView(new TreeViewConfiguration({ schema: TestArray })); + view.initialize([1, 2, 3]); + const root = view.root; + + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(root, "nodeChanged", ({ delta }) => deltas.push(delta)); + + root.insertAt(0, 0); + + assert.deepEqual(deltas, [[{ type: "insert", count: 1 }]]); + }); + + it(`removeAt(0) emits only a remove op (no leading retain)`, () => { + const view = getView(new TreeViewConfiguration({ schema: TestArray })); + view.initialize([1, 2, 3]); + const root = view.root; + + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(root, "nodeChanged", ({ delta }) => deltas.push(delta)); + + root.removeAt(0); + + assert.deepEqual(deltas, [[{ type: "remove", count: 1 }]]); + }); + + it(`removeAt(end) emits a leading retain followed by a remove`, () => { + const view = getView(new TreeViewConfiguration({ schema: TestArray })); + view.initialize([1, 2, 3]); + const root = view.root; + + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(root, "nodeChanged", ({ delta }) => deltas.push(delta)); + + root.removeAt(2); + + assert.deepEqual(deltas, [ + [ + { type: "retain", count: 2 }, + { type: "remove", count: 1 }, + ], + ]); + }); + + it(`moveRangeToEnd(0, 1) emits remove + retain + insert`, () => { + const view = getView(new TreeViewConfiguration({ schema: TestArray })); + view.initialize([1, 2, 3]); + const root = view.root; + + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(root, "nodeChanged", ({ delta }) => deltas.push(delta)); + + root.moveRangeToEnd(0, 1); + + assert.deepEqual(deltas, [ + [ + { type: "remove", count: 1 }, + { type: "retain", count: 2 }, + { type: "insert", count: 1 }, + ], + ]); + }); + + it(`nested child modification alongside removal produces a retain op`, () => { + // When an element's nested properties change (but it is not itself + // inserted or removed) AND another element is removed in the same delta, + // the marks for the array field include a {fields} mark for the + // unchanged-at-array-level element. deltaMarksToArrayOps converts that + // to a "retain" op. + const sb2 = new SchemaFactory("retain-delta"); + class RetainItem extends sb2.object("RetainItem", { value: sb2.number }) {} + class RetainArray extends sb2.array("RetainArray", [RetainItem]) {} + + const view = getView(new TreeViewConfiguration({ schema: RetainArray })); + view.initialize([{ value: 1 }, { value: 2 }, { value: 3 }]); + const root = view.root; + + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(root, "nodeChanged", ({ delta }) => deltas.push(delta)); + + // Use a fork to compose two changes into a single delta: modify + // element 0's nested property and remove element 1. The merged + // delta's marks are [{count:1,fields:{...}}, {count:1,detach:id}], + // which produce [retain 1, remove 1]. + const { forkView, forkCheckout } = getViewForForkedBranch(view); + forkView.root[0].value = 99; + forkView.root.removeAt(1); + view.checkout.merge(forkCheckout); + + assert.deepEqual(deltas, [ + [ + { type: "retain", count: 1 }, + { type: "remove", count: 1 }, + ], + ]); + }); + }); + it(`'nodeChanged' uses property keys, not stored keys, for the list of changed properties`, () => { const sb = new SchemaFactory("test"); class TestNode extends sb.object("root", { diff --git a/packages/dds/tree/src/test/simple-tree/core/treeNodeKernel.spec.ts b/packages/dds/tree/src/test/simple-tree/core/treeNodeKernel.spec.ts index 32c820dc8d59..827ac7abdacc 100644 --- a/packages/dds/tree/src/test/simple-tree/core/treeNodeKernel.spec.ts +++ b/packages/dds/tree/src/test/simple-tree/core/treeNodeKernel.spec.ts @@ -13,7 +13,13 @@ import { // TODO: test other things from "treeNodeKernel" file. // eslint-disable-next-line import-x/no-internal-modules } from "../../../simple-tree/core/treeNodeKernel.js"; -import { SchemaFactory, TreeBeta, TreeViewConfiguration } from "../../../simple-tree/index.js"; +import { + type ArrayNodeDeltaOp, + SchemaFactory, + SchemaFactoryAlpha, + TreeBeta, + TreeViewConfiguration, +} from "../../../simple-tree/index.js"; import { getView } from "../../utils.js"; import { describeHydration, hydrate } from "../utils.js"; @@ -145,6 +151,354 @@ describe("withBufferedTreeEvents", () => { }); }); +describe("array node delta in nodeChanged", () => { + // When two edits to the same array occur within a single withBufferedTreeEvents window, + // the marks from the first edit cannot be composed with those from the second, so the + // flushed event carries delta: undefined rather than stale or partial marks. + const schemaFactory = new SchemaFactory("test"); + const MyArray = schemaFactory.array("myArray", schemaFactory.number); + + describeHydration("delta presence", (init, hydrated) => { + it("delta is undefined for unhydrated arrays, defined for hydrated arrays", () => { + // Unhydrated nodes are not visited by the delta pipeline, so no field marks are + // available and delta is always undefined. Hydrated nodes have marks and delta + // is always defined (for a single unbuffered edit). + const myArray = init(MyArray, [1, 2, 3]); + + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(myArray, "nodeChanged", ({ delta }) => { + deltas.push(delta); + }); + + myArray.insertAtEnd(4); + + assert.equal(deltas.length, 1); + if (hydrated) { + assert.notEqual(deltas[0], undefined, "hydrated array should have a defined delta"); + } else { + assert.equal( + deltas[0], + undefined, + "unhydrated array delta should be undefined — no delta pipeline", + ); + } + }); + }); + + it("delta is defined for a single unbuffered edit", () => { + const myArray = hydrate(MyArray, [1, 2, 3]); + + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(myArray, "nodeChanged", ({ delta }) => { + deltas.push(delta); + }); + + myArray.insertAtEnd(4); + + assert.equal(deltas.length, 1); + assert.deepEqual(deltas[0], [ + { type: "retain", count: 3 }, + { type: "insert", count: 1 }, + ]); + }); + + it("delta is defined when array is modified once within buffered events", () => { + const myArray = hydrate(MyArray, [1, 2, 3]); + + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(myArray, "nodeChanged", ({ delta }) => { + deltas.push(delta); + }); + + withBufferedTreeEvents(() => { + myArray.insertAtEnd(4); + }); + + assert.equal(deltas.length, 1); + assert.deepEqual( + deltas[0], + [ + { type: "retain", count: 3 }, + { type: "insert", count: 1 }, + ], + "delta should carry the single batch's marks through the flush", + ); + }); + + it("delta is undefined when the same array is modified multiple times within buffered events", () => { + // Two edits to the same array within one withBufferedTreeEvents call produce two + // separate childrenChangedAfterBatch events for the same field. Because there is no + // delta-composition logic, the second set of marks invalidates the first, and the + // consumer receives delta: undefined rather than stale marks. + const myArray = hydrate(MyArray, [1, 2, 3]); + + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(myArray, "nodeChanged", ({ delta }) => { + deltas.push(delta); + }); + + withBufferedTreeEvents(() => { + myArray.insertAtEnd(4); // first batch of marks for EmptyKey + myArray.insertAtEnd(5); // second batch of marks — cannot be composed, marks invalidated + }); + + // The two edits are coalesced into a single nodeChanged event. + assert.equal(deltas.length, 1, "nodeChanged should fire exactly once when buffered"); + assert.equal( + deltas[0], + undefined, + "delta should be undefined when multiple batches touch the same field and cannot be composed", + ); + }); + + it("delta is undefined when the same array is modified 3+ times within buffered events", () => { + // Regression test: a third edit to the same array within one buffered window must also + // produce delta: undefined, not a spurious delta from only that third edit's marks. + const myArray = hydrate(MyArray, [1, 2, 3]); + + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(myArray, "nodeChanged", ({ delta }) => { + deltas.push(delta); + }); + + withBufferedTreeEvents(() => { + myArray.insertAtEnd(4); // 1st edit + myArray.insertAtEnd(5); // 2nd edit — marks become unavailable due to multiple batches + myArray.insertAtEnd(6); // 3rd edit — delta should still be undefined, not a spurious value + }); + + assert.equal(deltas.length, 1, "nodeChanged should fire exactly once when buffered"); + assert.equal( + deltas[0], + undefined, + "delta should be undefined when 3+ batches touch the same field (3rd batch must not re-populate marks)", + ); + }); + + it("delta is defined for each event when array is modified multiple times without buffering", () => { + // Without withBufferedTreeEvents, each edit produces its own nodeChanged with its own + // well-defined delta (no composition needed). + const myArray = hydrate(MyArray, [1, 2, 3]); + + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(myArray, "nodeChanged", ({ delta }) => { + deltas.push(delta); + }); + + myArray.insertAtEnd(4); + myArray.insertAtEnd(5); + + assert.equal(deltas.length, 2, "nodeChanged should fire once per edit when unbuffered"); + assert.deepEqual(deltas[0], [ + { type: "retain", count: 3 }, + { type: "insert", count: 1 }, + ]); + assert.deepEqual(deltas[1], [ + { type: "retain", count: 4 }, + { type: "insert", count: 1 }, + ]); + }); + + it("delta contains retain before insert for insert at middle position", () => { + // The sequence-field encoder strips trailing no-op marks, so elements after the + // insertion point are not included as a trailing retain — consumers should treat + // the remainder of the array as implicitly retained. + const myArray = hydrate(MyArray, [1, 2, 3]); + + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(myArray, "nodeChanged", ({ delta }) => { + deltas.push(delta); + }); + + myArray.insertAt(1, 99); + + assert.equal(deltas.length, 1); + assert.deepEqual(deltas[0], [ + { type: "retain", count: 1 }, + { type: "insert", count: 1 }, + ]); + }); + + it("delta contains retain and remove for removeAt from middle of array", () => { + // The sequence-field encoder strips trailing no-op marks, so the element after the + // removed position is not included as a trailing retain. + const myArray = hydrate(MyArray, [1, 2, 3]); + + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(myArray, "nodeChanged", ({ delta }) => { + deltas.push(delta); + }); + + myArray.removeAt(1); + + assert.equal(deltas.length, 1); + assert.deepEqual(deltas[0], [ + { type: "retain", count: 1 }, + { type: "remove", count: 1 }, + ]); + }); + + it("insert at position 0 produces no leading retain", () => { + // Sparse encoding: no retain is emitted before the insert when operating at the start. + const myArray = hydrate(MyArray, [1, 2, 3]); + + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(myArray, "nodeChanged", ({ delta }) => { + deltas.push(delta); + }); + + myArray.insertAt(0, 99); + + assert.equal(deltas.length, 1); + assert.deepEqual(deltas[0], [{ type: "insert", count: 1 }]); + }); + + it("remove at position 0 produces no leading retain", () => { + // Sparse encoding: no retain is emitted before the remove when operating at the start. + const myArray = hydrate(MyArray, [1, 2, 3]); + + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(myArray, "nodeChanged", ({ delta }) => { + deltas.push(delta); + }); + + myArray.removeAt(0); + + assert.equal(deltas.length, 1); + assert.deepEqual(deltas[0], [{ type: "remove", count: 1 }]); + }); + + it("object node nodeChanged does not include delta", () => { + const MyObj = schemaFactory.object("deltaTestObject", { x: schemaFactory.number }); + const obj = hydrate(MyObj, { x: 1 }); + + const events: { changedProperties?: ReadonlySet; delta?: unknown }[] = []; + TreeBeta.on(obj, "nodeChanged", (data) => events.push(data)); + + obj.x = 2; + + assert.equal(events.length, 1); + assert.deepEqual(events[0], { + changedProperties: new Set(["x"]), + }); + }); + + it("map node nodeChanged does not include delta", () => { + const MyMap = schemaFactory.map("deltaTestMap", schemaFactory.number); + const map = hydrate(MyMap, new Map([["a", 1]])); + + const events: { changedProperties?: ReadonlySet; delta?: unknown }[] = []; + TreeBeta.on(map, "nodeChanged", (data) => events.push(data)); + + map.set("a", 2); + + assert.equal(events.length, 1); + assert.deepEqual(events[0], { + changedProperties: new Set(["a"]), + }); + }); + + it("record node nodeChanged does not include delta", () => { + const schemaFactoryAlpha = new SchemaFactoryAlpha("test"); + const MyRecord = schemaFactoryAlpha.record("deltaTestRecord", schemaFactoryAlpha.number); + const record = hydrate(MyRecord, { a: 1 }); + + const events: { changedProperties?: ReadonlySet; delta?: unknown }[] = []; + TreeBeta.on(record, "nodeChanged", (data) => events.push(data)); + + record.a = 2; + + assert.equal(events.length, 1); + assert.deepEqual(events[0], { + changedProperties: new Set(["a"]), + }); + }); + + // Note: the `attach+detach` replacement branch in `deltaMarksToArrayOps` (both attach and + // detach set on the same DeltaMark) is not covered here because the sequence-field encoder + // never emits such marks for array (EmptyKey) fields in the current implementation. + // It is handled defensively in the conversion function but is not reachable via the public API. + + it("insert into empty array produces a single insert op with no leading retain", () => { + const myArray = hydrate(MyArray, []); + + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(myArray, "nodeChanged", ({ delta }) => { + deltas.push(delta); + }); + + myArray.insertAtEnd(1); + + assert.equal(deltas.length, 1); + assert.deepEqual(deltas[0], [{ type: "insert", count: 1 }]); + }); + + it("multi-element insert produces correct count in insert op", () => { + const myArray = hydrate(MyArray, [1, 2]); + + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(myArray, "nodeChanged", ({ delta }) => { + deltas.push(delta); + }); + + myArray.insertAt(1, 10, 20, 30); + + assert.equal(deltas.length, 1); + assert.deepEqual(deltas[0], [ + { type: "retain", count: 1 }, + { type: "insert", count: 3 }, + ]); + }); + + it("removeRange produces correct count in remove op", () => { + const myArray = hydrate(MyArray, [1, 2, 3, 4, 5]); + + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(myArray, "nodeChanged", ({ delta }) => { + deltas.push(delta); + }); + + myArray.removeRange(1, 4); // removes elements at indices 1, 2, 3 + + assert.equal(deltas.length, 1); + assert.deepEqual(deltas[0], [ + { type: "retain", count: 1 }, + { type: "remove", count: 3 }, + ]); + }); + + it("delta is defined when two different arrays are modified within the same buffered events", () => { + // Modifying two *different* arrays within one buffer window should not invalidate either + // delta, since the marks are for different fields / different anchor nodes. + const Parent = schemaFactory.object("myParent", { + array1: MyArray, + array2: MyArray, + }); + const parent = hydrate(Parent, { array1: [1, 2], array2: [3, 4] }); + + const delta1: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + const delta2: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(parent.array1, "nodeChanged", ({ delta }) => delta1.push(delta)); + TreeBeta.on(parent.array2, "nodeChanged", ({ delta }) => delta2.push(delta)); + + withBufferedTreeEvents(() => { + parent.array1.insertAtEnd(5); + parent.array2.insertAtEnd(6); + }); + + assert.equal(delta1.length, 1); + assert.deepEqual(delta1[0], [ + { type: "retain", count: 2 }, + { type: "insert", count: 1 }, + ]); + assert.equal(delta2.length, 1); + assert.deepEqual(delta2[0], [ + { type: "retain", count: 2 }, + { type: "insert", count: 1 }, + ]); + }); +}); + describe("array move events", () => { const schemaFactory = new SchemaFactory("test"); @@ -232,4 +586,67 @@ describe("array move events", () => { ); }); }); + + describe("move delta payloads", () => { + const sf = new SchemaFactory("move-delta"); + const MoveArray = sf.array("MoveArray", sf.number); + + it("move within array emits remove + retain + insert delta", () => { + const arr = hydrate(MoveArray, [1, 2, 3]); + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(arr, "nodeChanged", ({ delta }) => deltas.push(delta)); + + arr.moveToEnd(0); + + assert.deepEqual(deltas, [ + [ + { type: "remove", count: 1 }, + { type: "retain", count: 2 }, + { type: "insert", count: 1 }, + ], + ]); + }); + + it("cross-field move emits correct delta on source and destination arrays", () => { + const MoveParent = sf.object("MoveParent", { + array1: MoveArray, + array2: MoveArray, + }); + const parent = hydrate(MoveParent, { array1: [1, 2, 3], array2: [4, 5] }); + const delta1: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + const delta2: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(parent.array1, "nodeChanged", ({ delta }) => delta1.push(delta)); + TreeBeta.on(parent.array2, "nodeChanged", ({ delta }) => delta2.push(delta)); + + // Move element 0 of array2 (value 4) to the end of array1. + parent.array1.moveToEnd(0, parent.array2); + + // Destination: retain the existing 3 elements, then insert the moved one. + assert.deepEqual(delta1, [ + [ + { type: "retain", count: 3 }, + { type: "insert", count: 1 }, + ], + ]); + // Source: the moved element is removed from position 0. + assert.deepEqual(delta2, [[{ type: "remove", count: 1 }]]); + }); + + it("moveRangeToEnd emits correct count in remove and insert ops", () => { + const arr = hydrate(MoveArray, [1, 2, 3, 4, 5]); + const deltas: (readonly ArrayNodeDeltaOp[] | undefined)[] = []; + TreeBeta.on(arr, "nodeChanged", ({ delta }) => deltas.push(delta)); + + // Move elements at indices 1 and 2 (values 2, 3) to the end. + arr.moveRangeToEnd(1, 3); + + assert.equal(deltas.length, 1); + assert.deepEqual(deltas[0], [ + { type: "retain", count: 1 }, + { type: "remove", count: 2 }, + { type: "retain", count: 2 }, + { type: "insert", count: 2 }, + ]); + }); + }); }); 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 8b7e4a8c009d..7f3d56df202c 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 @@ -102,6 +102,18 @@ export interface ArrayNodeCustomizableSchemaUnsafe, SimpleArrayNodeSchema { } +// @beta @sealed +export type ArrayNodeDeltaOp = { + readonly type: "retain"; + readonly count: number; +} | { + readonly type: "insert"; + readonly count: number; +} | { + readonly type: "remove"; + readonly count: number; +}; + // @alpha @sealed @system export interface ArrayNodePojoEmulationSchema extends TreeNodeSchemaNonClass & WithType, Iterable>, ImplicitlyConstructable, T, undefined, TCustomMetadata>, SimpleArrayNodeSchema { } @@ -1140,6 +1152,7 @@ type NodeBuilderData> = // @beta @sealed export interface NodeChangedData { readonly changedProperties?: ReadonlySet ? string & keyof TInfo : string>; + readonly delta?: readonly ArrayNodeDeltaOp[]; } // @public @@ -2000,7 +2013,9 @@ export interface TreeChangeEvents { // @beta @sealed export interface TreeChangeEventsBeta extends TreeChangeEvents { - nodeChanged: (data: NodeChangedData & (TNode extends WithType ? Required, "changedProperties">> : unknown)) => void; + nodeChanged: (data: NodeChangedData & (TNode extends WithType ? Required, "changedProperties">> : TNode extends WithType ? { + readonly delta: readonly ArrayNodeDeltaOp[] | undefined; + } : unknown)) => void; } // @alpha diff --git a/packages/framework/fluid-framework/api-report/fluid-framework.beta.api.md b/packages/framework/fluid-framework/api-report/fluid-framework.beta.api.md index 9fd19196ed5a..7a91f92161f1 100644 --- a/packages/framework/fluid-framework/api-report/fluid-framework.beta.api.md +++ b/packages/framework/fluid-framework/api-report/fluid-framework.beta.api.md @@ -86,6 +86,18 @@ type ApplyKindInput(view: TreeView): TreeViewBeta; @@ -732,6 +744,7 @@ type NodeBuilderData> = // @beta @sealed export interface NodeChangedData { readonly changedProperties?: ReadonlySet ? string & keyof TInfo : string>; + readonly delta?: readonly ArrayNodeDeltaOp[]; } // @public @@ -1273,7 +1286,9 @@ export interface TreeChangeEvents { // @beta @sealed export interface TreeChangeEventsBeta extends TreeChangeEvents { - nodeChanged: (data: NodeChangedData & (TNode extends WithType ? Required, "changedProperties">> : unknown)) => void; + nodeChanged: (data: NodeChangedData & (TNode extends WithType ? Required, "changedProperties">> : TNode extends WithType ? { + readonly delta: readonly ArrayNodeDeltaOp[] | undefined; + } : unknown)) => void; } // @beta @input diff --git a/packages/framework/fluid-framework/api-report/fluid-framework.legacy.beta.api.md b/packages/framework/fluid-framework/api-report/fluid-framework.legacy.beta.api.md index a4849cd1921a..0f1a23219554 100644 --- a/packages/framework/fluid-framework/api-report/fluid-framework.legacy.beta.api.md +++ b/packages/framework/fluid-framework/api-report/fluid-framework.legacy.beta.api.md @@ -86,6 +86,18 @@ type ApplyKindInput(view: TreeView): TreeViewBeta; @@ -1012,6 +1024,7 @@ type NodeBuilderData> = // @beta @sealed export interface NodeChangedData { readonly changedProperties?: ReadonlySet ? string & keyof TInfo : string>; + readonly delta?: readonly ArrayNodeDeltaOp[]; } // @public @@ -1633,7 +1646,9 @@ export interface TreeChangeEvents { // @beta @sealed export interface TreeChangeEventsBeta extends TreeChangeEvents { - nodeChanged: (data: NodeChangedData & (TNode extends WithType ? Required, "changedProperties">> : unknown)) => void; + nodeChanged: (data: NodeChangedData & (TNode extends WithType ? Required, "changedProperties">> : TNode extends WithType ? { + readonly delta: readonly ArrayNodeDeltaOp[] | undefined; + } : unknown)) => void; } // @beta @input