@fluidframework/tree 2.61.0-355054 → 2.61.0-355516

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/alpha.d.ts +1 -1
  2. package/api-report/tree.alpha.api.md +10 -27
  3. package/api-report/tree.beta.api.md +5 -22
  4. package/api-report/tree.legacy.beta.api.md +5 -22
  5. package/api-report/tree.legacy.public.api.md +4 -21
  6. package/api-report/tree.public.api.md +4 -21
  7. package/beta.d.ts +1 -1
  8. package/dist/alpha.d.ts +1 -0
  9. package/dist/beta.d.ts +1 -0
  10. package/dist/core/tree/anchorSet.d.ts +3 -3
  11. package/dist/core/tree/anchorSet.d.ts.map +1 -1
  12. package/dist/core/tree/anchorSet.js.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/legacy.d.ts +1 -0
  17. package/dist/packageVersion.d.ts +1 -1
  18. package/dist/packageVersion.js +1 -1
  19. package/dist/packageVersion.js.map +1 -1
  20. package/dist/public.d.ts +1 -0
  21. package/dist/simple-tree/api/index.d.ts +1 -1
  22. package/dist/simple-tree/api/index.d.ts.map +1 -1
  23. package/dist/simple-tree/api/index.js.map +1 -1
  24. package/dist/simple-tree/api/schemaFactory.d.ts +11 -83
  25. package/dist/simple-tree/api/schemaFactory.d.ts.map +1 -1
  26. package/dist/simple-tree/api/schemaFactory.js +26 -82
  27. package/dist/simple-tree/api/schemaFactory.js.map +1 -1
  28. package/dist/simple-tree/core/index.d.ts +1 -1
  29. package/dist/simple-tree/core/index.d.ts.map +1 -1
  30. package/dist/simple-tree/core/index.js +2 -1
  31. package/dist/simple-tree/core/index.js.map +1 -1
  32. package/dist/simple-tree/core/treeNodeKernel.d.ts +12 -1
  33. package/dist/simple-tree/core/treeNodeKernel.d.ts.map +1 -1
  34. package/dist/simple-tree/core/treeNodeKernel.js +188 -43
  35. package/dist/simple-tree/core/treeNodeKernel.js.map +1 -1
  36. package/dist/simple-tree/core/unhydratedFlexTree.d.ts +4 -3
  37. package/dist/simple-tree/core/unhydratedFlexTree.d.ts.map +1 -1
  38. package/dist/simple-tree/core/unhydratedFlexTree.js +22 -6
  39. package/dist/simple-tree/core/unhydratedFlexTree.js.map +1 -1
  40. package/dist/simple-tree/index.d.ts +2 -2
  41. package/dist/simple-tree/index.d.ts.map +1 -1
  42. package/dist/simple-tree/index.js +3 -2
  43. package/dist/simple-tree/index.js.map +1 -1
  44. package/dist/simple-tree/node-kinds/array/arrayNode.d.ts.map +1 -1
  45. package/dist/simple-tree/node-kinds/array/arrayNode.js +13 -6
  46. package/dist/simple-tree/node-kinds/array/arrayNode.js.map +1 -1
  47. package/dist/tableSchema.d.ts.map +1 -1
  48. package/dist/tableSchema.js +15 -10
  49. package/dist/tableSchema.js.map +1 -1
  50. package/internal.d.ts +1 -1
  51. package/legacy.d.ts +1 -1
  52. package/lib/alpha.d.ts +1 -0
  53. package/lib/beta.d.ts +1 -0
  54. package/lib/core/tree/anchorSet.d.ts +3 -3
  55. package/lib/core/tree/anchorSet.d.ts.map +1 -1
  56. package/lib/core/tree/anchorSet.js.map +1 -1
  57. package/lib/index.d.ts +1 -1
  58. package/lib/index.d.ts.map +1 -1
  59. package/lib/index.js.map +1 -1
  60. package/lib/legacy.d.ts +1 -0
  61. package/lib/packageVersion.d.ts +1 -1
  62. package/lib/packageVersion.js +1 -1
  63. package/lib/packageVersion.js.map +1 -1
  64. package/lib/public.d.ts +1 -0
  65. package/lib/simple-tree/api/index.d.ts +1 -1
  66. package/lib/simple-tree/api/index.d.ts.map +1 -1
  67. package/lib/simple-tree/api/index.js.map +1 -1
  68. package/lib/simple-tree/api/schemaFactory.d.ts +11 -83
  69. package/lib/simple-tree/api/schemaFactory.d.ts.map +1 -1
  70. package/lib/simple-tree/api/schemaFactory.js +25 -81
  71. package/lib/simple-tree/api/schemaFactory.js.map +1 -1
  72. package/lib/simple-tree/core/index.d.ts +1 -1
  73. package/lib/simple-tree/core/index.d.ts.map +1 -1
  74. package/lib/simple-tree/core/index.js +1 -1
  75. package/lib/simple-tree/core/index.js.map +1 -1
  76. package/lib/simple-tree/core/treeNodeKernel.d.ts +12 -1
  77. package/lib/simple-tree/core/treeNodeKernel.d.ts.map +1 -1
  78. package/lib/simple-tree/core/treeNodeKernel.js +187 -43
  79. package/lib/simple-tree/core/treeNodeKernel.js.map +1 -1
  80. package/lib/simple-tree/core/unhydratedFlexTree.d.ts +4 -3
  81. package/lib/simple-tree/core/unhydratedFlexTree.d.ts.map +1 -1
  82. package/lib/simple-tree/core/unhydratedFlexTree.js +22 -6
  83. package/lib/simple-tree/core/unhydratedFlexTree.js.map +1 -1
  84. package/lib/simple-tree/index.d.ts +2 -2
  85. package/lib/simple-tree/index.d.ts.map +1 -1
  86. package/lib/simple-tree/index.js +1 -1
  87. package/lib/simple-tree/index.js.map +1 -1
  88. package/lib/simple-tree/node-kinds/array/arrayNode.d.ts.map +1 -1
  89. package/lib/simple-tree/node-kinds/array/arrayNode.js +14 -7
  90. package/lib/simple-tree/node-kinds/array/arrayNode.js.map +1 -1
  91. package/lib/tableSchema.d.ts.map +1 -1
  92. package/lib/tableSchema.js +16 -11
  93. package/lib/tableSchema.js.map +1 -1
  94. package/package.json +22 -22
  95. package/src/core/tree/anchorSet.ts +2 -2
  96. package/src/index.ts +1 -0
  97. package/src/packageVersion.ts +1 -1
  98. package/src/simple-tree/api/index.ts +1 -0
  99. package/src/simple-tree/api/schemaFactory.ts +31 -103
  100. package/src/simple-tree/core/index.ts +1 -0
  101. package/src/simple-tree/core/treeNodeKernel.ts +242 -44
  102. package/src/simple-tree/core/unhydratedFlexTree.ts +26 -3
  103. package/src/simple-tree/index.ts +2 -0
  104. package/src/simple-tree/node-kinds/array/arrayNode.ts +19 -11
  105. package/src/tableSchema.ts +15 -9
@@ -174,6 +174,33 @@ export type ScopedSchemaName<
174
174
 
175
175
  const schemaStaticsPublic: SchemaStatics = schemaStatics;
176
176
 
177
+ /**
178
+ * Create a class with `Statics` as both static properties and member properties.
179
+ * @privateRemarks
180
+ * An attempt was made to let this take in a base class so it could be used again on SchemaFactoryAlpha.
181
+ * This was unsuccessful, mostly due to issues with trying to manipulate constructor types.
182
+ */
183
+ function classWithStatics<Statics extends object>(
184
+ statics: Statics,
185
+ ): Statics & (new () => Statics) {
186
+ // eslint-disable-next-line @typescript-eslint/no-extraneous-class
187
+ class WithStatics {}
188
+
189
+ Object.assign(WithStatics.prototype, statics);
190
+ Object.assign(WithStatics, statics);
191
+ return WithStatics as Statics & (new () => Statics);
192
+ }
193
+
194
+ /**
195
+ * Base class for SchemaFactory, exposes {@link SchemaStatics} as both static properties and member properties.
196
+ * @remarks
197
+ * Do not use this directly, use {@link SchemaFactory} instead.
198
+ * @privateRemarks
199
+ * Exported only as a workaround for {@link https://github.com/microsoft/TypeScript/issues/59550} and {@link https://github.com/microsoft/rushstack/issues/4429}.
200
+ * @system @public
201
+ */
202
+ export const SchemaFactory_base = classWithStatics(schemaStaticsPublic);
203
+
177
204
  // TODO:
178
205
  // SchemaFactory.array references should link to the correct overloads, however the syntax for this does not seems to work currently for methods unless the they are not qualified with the class.
179
206
  // API-Extractor requires such links to be qualified with the class, so it can't work.
@@ -289,8 +316,7 @@ const schemaStaticsPublic: SchemaStatics = schemaStatics;
289
316
  export class SchemaFactory<
290
317
  out TScope extends string | undefined = string | undefined,
291
318
  TName extends number | string = string,
292
- > implements SchemaStatics
293
- {
319
+ > extends SchemaFactory_base {
294
320
  /**
295
321
  * TODO:
296
322
  * If users of this generate the same name because two different schema with the same identifier were used,
@@ -339,67 +365,9 @@ export class SchemaFactory<
339
365
  * ```
340
366
  */
341
367
  public readonly scope: TScope,
342
- ) {}
343
-
344
- /**
345
- * {@inheritDoc SchemaStatics.string}
346
- */
347
- public readonly string = schemaStaticsPublic.string;
348
-
349
- /**
350
- * {@inheritDoc SchemaStatics.number}
351
- */
352
- public readonly number = schemaStaticsPublic.number;
353
-
354
- /**
355
- * {@inheritDoc SchemaStatics.boolean}
356
- */
357
- public readonly boolean = schemaStaticsPublic.boolean;
358
-
359
- /**
360
- * {@inheritDoc SchemaStatics.null}
361
- */
362
- public readonly null = schemaStaticsPublic.null;
363
-
364
- /**
365
- * {@inheritDoc SchemaStatics.handle}
366
- */
367
- public readonly handle = schemaStaticsPublic.handle;
368
-
369
- /**
370
- * {@inheritDoc SchemaStatics.leaves}
371
- */
372
- public readonly leaves = schemaStaticsPublic.leaves;
373
-
374
- /**
375
- * {@inheritDoc SchemaStatics.string}
376
- */
377
- public static readonly string = schemaStaticsPublic.string;
378
-
379
- /**
380
- * {@inheritDoc SchemaStatics.number}
381
- */
382
- public static readonly number = schemaStaticsPublic.number;
383
-
384
- /**
385
- * {@inheritDoc SchemaStatics.boolean}
386
- */
387
- public static readonly boolean = schemaStaticsPublic.boolean;
388
-
389
- /**
390
- * {@inheritDoc SchemaStatics.null}
391
- */
392
- public static readonly null = schemaStaticsPublic.null;
393
-
394
- /**
395
- * {@inheritDoc SchemaStatics.handle}
396
- */
397
- public static readonly handle = schemaStaticsPublic.handle;
398
-
399
- /**
400
- * {@inheritDoc SchemaStatics.leaves}
401
- */
402
- public static readonly leaves = schemaStaticsPublic.leaves;
368
+ ) {
369
+ super();
370
+ }
403
371
 
404
372
  /**
405
373
  * Define a {@link TreeNodeSchemaClass} for a {@link TreeObjectNode}.
@@ -807,46 +775,6 @@ export class SchemaFactory<
807
775
  >;
808
776
  }
809
777
 
810
- /**
811
- * {@inheritDoc SchemaStatics.optional}
812
- */
813
- public readonly optional = schemaStaticsPublic.optional;
814
-
815
- /**
816
- * {@inheritDoc SchemaStatics.required}
817
- */
818
- public readonly required = schemaStaticsPublic.required;
819
-
820
- /**
821
- * {@inheritDoc SchemaStatics.optionalRecursive}
822
- */
823
- public readonly optionalRecursive = schemaStaticsPublic.optionalRecursive;
824
-
825
- /**
826
- * {@inheritDoc SchemaStatics.requiredRecursive}
827
- */
828
- public readonly requiredRecursive = schemaStaticsPublic.requiredRecursive;
829
-
830
- /**
831
- * {@inheritDoc SchemaStatics.optional}
832
- */
833
- public static readonly optional = schemaStaticsPublic.optional;
834
-
835
- /**
836
- * {@inheritDoc SchemaStatics.required}
837
- */
838
- public static readonly required = schemaStaticsPublic.required;
839
-
840
- /**
841
- * {@inheritDoc SchemaStatics.optionalRecursive}
842
- */
843
- public static readonly optionalRecursive = schemaStaticsPublic.optionalRecursive;
844
-
845
- /**
846
- * {@inheritDoc SchemaStatics.requiredRecursive}
847
- */
848
- public static readonly requiredRecursive = schemaStaticsPublic.requiredRecursive;
849
-
850
778
  /**
851
779
  * A special readonly field which holds an identifier string for an object node.
852
780
  * @remarks
@@ -14,6 +14,7 @@ export {
14
14
  treeNodeFromAnchor,
15
15
  getSimpleNodeSchemaFromInnerNode,
16
16
  SimpleContextSlot,
17
+ withBufferedTreeEvents,
17
18
  } from "./treeNodeKernel.js";
18
19
  export { type WithType, typeNameSymbol, typeSchemaSymbol } from "./withType.js";
19
20
  export {
@@ -4,8 +4,13 @@
4
4
  */
5
5
 
6
6
  import { createEmitter } from "@fluid-internal/client-utils";
7
- import type { Listenable, Off } from "@fluidframework/core-interfaces";
8
- import { assert, Lazy, fail, debugAssert } from "@fluidframework/core-utils/internal";
7
+ import type { HasListeners, Listenable, Off } from "@fluidframework/core-interfaces/internal";
8
+ import {
9
+ assert,
10
+ fail,
11
+ debugAssert,
12
+ unreachableCase,
13
+ } from "@fluidframework/core-utils/internal";
9
14
  import { UsageError } from "@fluidframework/telemetry-utils/internal";
10
15
 
11
16
  import {
@@ -13,6 +18,7 @@ import {
13
18
  type AnchorEvents,
14
19
  type AnchorNode,
15
20
  type AnchorSet,
21
+ type FieldKey,
16
22
  type TreeValue,
17
23
  type UpPath,
18
24
  } from "../../core/index.js";
@@ -74,7 +80,6 @@ export function tryGetTreeNodeSchema(value: unknown): undefined | TreeNodeSchema
74
80
 
75
81
  /** The {@link HydrationState} of a {@link TreeNodeKernel} before the kernel is hydrated */
76
82
  interface UnhydratedState {
77
- off: Off;
78
83
  readonly innerNode: UnhydratedFlexTreeNode;
79
84
  }
80
85
 
@@ -126,7 +131,7 @@ export class TreeNodeKernel {
126
131
  * This means optimizations like skipping processing data in subtrees where no subtreeChanged events are subscribed to would be able to work,
127
132
  * since the kernel does not unconditionally subscribe to those events (like a design which simply forwards all events would).
128
133
  */
129
- readonly #unhydratedEvents = new Lazy(createEmitter<KernelEvents>);
134
+ readonly #eventBuffer: KernelEventBuffer;
130
135
 
131
136
  /**
132
137
  * Create a TreeNodeKernel which can be looked up with {@link getKernel}.
@@ -149,38 +154,21 @@ export class TreeNodeKernel {
149
154
 
150
155
  if (innerNode instanceof UnhydratedFlexTreeNode) {
151
156
  // Unhydrated case
157
+
152
158
  debugAssert(() => innerNode.treeNode === undefined);
153
159
  innerNode.treeNode = node;
154
- // Register for change events from the unhydrated flex node.
155
- // These will be fired if the unhydrated node is edited, and will also be forwarded later to the hydrated node.
160
+
156
161
  this.#hydrationState = {
157
162
  innerNode,
158
- off: innerNode.events.on("childrenChangedAfterBatch", ({ changedFields }) => {
159
- this.#unhydratedEvents.value.emit("childrenChangedAfterBatch", {
160
- changedFields,
161
- });
162
-
163
- let unhydratedNode: UnhydratedFlexTreeNode | undefined = innerNode;
164
- while (unhydratedNode !== undefined) {
165
- const treeNode = unhydratedNode.treeNode;
166
- if (treeNode !== undefined) {
167
- const kernel = getKernel(treeNode);
168
- kernel.#unhydratedEvents.value.emit("subtreeChangedAfterBatch");
169
- }
170
- const parentNode: FlexTreeNode | undefined =
171
- unhydratedNode.parentField.parent.parent;
172
- assert(
173
- parentNode === undefined || parentNode instanceof UnhydratedFlexTreeNode,
174
- 0xb76 /* Unhydrated node's parent should be an unhydrated node */,
175
- );
176
- unhydratedNode = parentNode;
177
- }
178
- }),
179
163
  };
164
+
165
+ this.#eventBuffer = new KernelEventBuffer(innerNode.events);
180
166
  } else {
181
167
  // Hydrated case
182
168
  this.#hydrationState = this.createHydratedState(innerNode.anchorNode);
183
169
  this.#hydrationState.innerNode = innerNode;
170
+
171
+ this.#eventBuffer = new KernelEventBuffer(innerNode.anchorNode.events);
184
172
  }
185
173
  }
186
174
 
@@ -213,19 +201,8 @@ export class TreeNodeKernel {
213
201
  this.#hydrationState = this.createHydratedState(anchorNode);
214
202
  this.#hydrationState.offAnchorNode.add(() => anchors.forget(anchor));
215
203
 
216
- // If needed, register forwarding emitters for events from before hydration
217
- if (this.#unhydratedEvents.evaluated) {
218
- const events = this.#unhydratedEvents.value;
219
- for (const eventName of kernelEvents) {
220
- if (events.hasListeners(eventName)) {
221
- this.#hydrationState.offAnchorNode.add(
222
- // Argument is forwarded between matching events, so the type should be correct.
223
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
224
- anchorNode.events.on(eventName, (arg: any) => events.emit(eventName, arg)),
225
- );
226
- }
227
- }
228
- }
204
+ // Lazily migrate existing event listeners to the anchor node
205
+ this.#eventBuffer.migrateEventSource(anchorNode.events);
229
206
  }
230
207
 
231
208
  private createHydratedState(anchorNode: AnchorNode): HydratedState {
@@ -267,10 +244,7 @@ export class TreeNodeKernel {
267
244
  }
268
245
 
269
246
  public get events(): Listenable<KernelEvents> {
270
- // Retrieve the correct events object based on whether this node is pre or post hydration.
271
- return isHydrated(this.#hydrationState)
272
- ? this.#hydrationState.anchorNode.events
273
- : this.#unhydratedEvents.value;
247
+ return this.#eventBuffer;
274
248
  }
275
249
 
276
250
  public dispose(): void {
@@ -281,6 +255,7 @@ export class TreeNodeKernel {
281
255
  off();
282
256
  }
283
257
  }
258
+ this.#eventBuffer.dispose();
284
259
  // TODO: go to the context and remove myself from withAnchors
285
260
  }
286
261
 
@@ -354,6 +329,229 @@ const kernelEvents = ["childrenChangedAfterBatch", "subtreeChangedAfterBatch"] a
354
329
 
355
330
  type KernelEvents = Pick<AnchorEvents, (typeof kernelEvents)[number]>;
356
331
 
332
+ // #region TreeNodeEventBuffer
333
+
334
+ /**
335
+ * Whether or not events from {@link TreeNodeKernel} should be buffered instead of emitted immediately.
336
+ */
337
+ let bufferTreeEvents: boolean = false;
338
+
339
+ /**
340
+ * Call the provided callback with {@link TreeNode}s' events paused until after the callback's completion.
341
+ *
342
+ * Events that would otherwise have been emitted immediately are merged and buffered until after the
343
+ * provided callback has been completed.
344
+ *
345
+ * @remarks
346
+ * Note: this should be used with caution. User application behaviors are implicitly coupled to event timing.
347
+ * Disrupting this timing can lead to unexpected behavior.
348
+ */
349
+ export function withBufferedTreeEvents(callback: () => void): void {
350
+ if (bufferTreeEvents) {
351
+ // Already buffering - just run the callback
352
+ callback();
353
+ } else {
354
+ bufferTreeEvents = true;
355
+ try {
356
+ callback();
357
+ } finally {
358
+ bufferTreeEvents = false;
359
+ flushEventsEmitter.emit("flush");
360
+ }
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Event emitter to notify subscribers when tree events buffered due to {@link withBufferedTreeEvents} should be flushed.
366
+ */
367
+ const flushEventsEmitter = createEmitter<{
368
+ flush: () => void;
369
+ }>();
370
+
371
+ /**
372
+ * Event emitter for {@link TreeNodeKernel}, which optionally buffers events based on {@link bufferTreeEvents}.
373
+ * @remarks Listens to {@link flushEventsEmitter} to know when to flush any buffered events.
374
+ */
375
+ class KernelEventBuffer implements Listenable<KernelEvents> {
376
+ #disposed: boolean = false;
377
+
378
+ /**
379
+ * Listen to {@link flushEventsEmitter} to know when to flush buffered events.
380
+ */
381
+ readonly #disposeOnFlushListener = flushEventsEmitter.on("flush", () => {
382
+ this.flush();
383
+ });
384
+
385
+ readonly #events = createEmitter<KernelEvents>();
386
+
387
+ #eventSource: Listenable<KernelEvents> & HasListeners<KernelEvents>;
388
+ #disposeSourceListeners: Map<keyof KernelEvents, Off> = new Map();
389
+
390
+ /**
391
+ * Buffer of fields that have changed since events were paused.
392
+ * When events are flushed, a single {@link AnchorEvents.childrenChangedAfterBatch} event will be emitted
393
+ * containing the accumulated set of changed fields.
394
+ */
395
+ readonly #childrenChangedBuffer: Set<FieldKey> = new Set();
396
+
397
+ /**
398
+ * Whether or not the subtree has changed since events were paused.
399
+ * When events are flushed, a single {@link AnchorEvents.subTreeChanged} event will be emitted if and only
400
+ * if the subtree has changed.
401
+ */
402
+ #subTreeChangedBuffer: boolean = false;
403
+
404
+ public constructor(
405
+ /**
406
+ * Source of the kernel events.
407
+ * Subscriptions will be created on-demand when listeners are added to this.events,
408
+ * and those subscriptions will be cleaned up when all corresponding listeners have been removed.
409
+ */
410
+ eventSource: Listenable<KernelEvents> & HasListeners<KernelEvents>,
411
+ ) {
412
+ this.#eventSource = eventSource;
413
+ }
414
+
415
+ /**
416
+ * Migrate this event buffer to a new event source.
417
+ *
418
+ * @remarks
419
+ * Cleans up any existing event subscriptions from the old source.
420
+ * Binds events to the new source for each event with active listeners.
421
+ */
422
+ public migrateEventSource(
423
+ newSource: Listenable<KernelEvents> & HasListeners<KernelEvents>,
424
+ ): void {
425
+ // Unsubscribe from the old source
426
+ this.#disposeSourceListeners.forEach((off) => off());
427
+ this.#disposeSourceListeners.clear();
428
+
429
+ this.#eventSource = newSource;
430
+
431
+ if (this.#events.hasListeners("childrenChangedAfterBatch")) {
432
+ const off = this.#eventSource.on("childrenChangedAfterBatch", ({ changedFields }) =>
433
+ this.#emit("childrenChangedAfterBatch", { changedFields }),
434
+ );
435
+ this.#disposeSourceListeners.set("childrenChangedAfterBatch", off);
436
+ }
437
+ if (this.#events.hasListeners("subtreeChangedAfterBatch")) {
438
+ const off = this.#eventSource.on("subtreeChangedAfterBatch", () =>
439
+ this.#emit("subtreeChangedAfterBatch"),
440
+ );
441
+ this.#disposeSourceListeners.set("subtreeChangedAfterBatch", off);
442
+ }
443
+ }
444
+
445
+ public on(eventName: keyof KernelEvents, listener: KernelEvents[typeof eventName]): Off {
446
+ // Lazily bind event listeners to the source.
447
+ // If we do not have any existing listeners for this event, then we need to bind to the source.
448
+ if (!this.#events.hasListeners(eventName)) {
449
+ assert(
450
+ !this.#disposeSourceListeners.has(eventName),
451
+ "Should not have a dispose function without listeners",
452
+ );
453
+
454
+ const off = this.#eventSource.on(eventName, (args) => this.#emit(eventName, args));
455
+ this.#disposeSourceListeners.set(eventName, off);
456
+ }
457
+
458
+ this.#events.on(eventName, listener);
459
+ return () => this.off(eventName, listener);
460
+ }
461
+
462
+ public off(eventName: keyof KernelEvents, listener: KernelEvents[typeof eventName]): void {
463
+ this.#events.off(eventName, listener);
464
+
465
+ // If there are no remaining listeners for the event, unbind from the source
466
+ if (!this.#events.hasListeners(eventName)) {
467
+ const off = this.#disposeSourceListeners.get(eventName);
468
+ off?.();
469
+ this.#disposeSourceListeners.delete(eventName);
470
+ }
471
+ }
472
+
473
+ #emit(
474
+ eventName: keyof KernelEvents,
475
+ arg?: {
476
+ changedFields: ReadonlySet<FieldKey>;
477
+ },
478
+ ): void {
479
+ this.#assertNotDisposed();
480
+ switch (eventName) {
481
+ case "childrenChangedAfterBatch":
482
+ assert(arg !== undefined, "childrenChangedAfterBatch should have arg");
483
+ return this.#handleChildrenChangedAfterBatch(arg.changedFields);
484
+ case "subtreeChangedAfterBatch":
485
+ return this.#handleSubtreeChangedAfterBatch();
486
+ default:
487
+ unreachableCase(eventName);
488
+ }
489
+ }
490
+
491
+ #handleChildrenChangedAfterBatch(changedFields: ReadonlySet<FieldKey>): void {
492
+ if (bufferTreeEvents) {
493
+ for (const fieldKey of changedFields) {
494
+ this.#childrenChangedBuffer.add(fieldKey);
495
+ }
496
+ } else {
497
+ this.#events.emit("childrenChangedAfterBatch", { changedFields });
498
+ }
499
+ }
500
+
501
+ #handleSubtreeChangedAfterBatch(): void {
502
+ if (bufferTreeEvents) {
503
+ this.#subTreeChangedBuffer = true;
504
+ } else {
505
+ this.#events.emit("subtreeChangedAfterBatch");
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Flushes any events buffered due to {@link withBufferedTreeEvents}.
511
+ */
512
+ public flush(): void {
513
+ this.#assertNotDisposed();
514
+
515
+ if (this.#childrenChangedBuffer.size > 0) {
516
+ this.#events.emit("childrenChangedAfterBatch", {
517
+ changedFields: this.#childrenChangedBuffer,
518
+ });
519
+ this.#childrenChangedBuffer.clear();
520
+ }
521
+
522
+ if (this.#subTreeChangedBuffer) {
523
+ this.#events.emit("subtreeChangedAfterBatch");
524
+ this.#subTreeChangedBuffer = false;
525
+ }
526
+ }
527
+
528
+ #assertNotDisposed(): void {
529
+ assert(!this.#disposed, "Event handler disposed.");
530
+ }
531
+
532
+ public dispose(): void {
533
+ if (this.#disposed) {
534
+ return;
535
+ }
536
+
537
+ assert(
538
+ this.#childrenChangedBuffer.size === 0 && !this.#subTreeChangedBuffer,
539
+ "Buffered kernel events should have been flushed before disposing.",
540
+ );
541
+
542
+ this.#disposeOnFlushListener();
543
+ this.#disposeSourceListeners.forEach((off) => off());
544
+ this.#disposeSourceListeners.clear();
545
+
546
+ this.#childrenChangedBuffer.clear();
547
+ this.#subTreeChangedBuffer = false;
548
+
549
+ this.#disposed = true;
550
+ }
551
+ }
552
+
553
+ // #endregion
554
+
357
555
  /**
358
556
  * For "cooked" nodes this is a HydratedFlexTreeNode thats a projection of forest content.
359
557
  * For {@link Unhydrated} nodes this is a UnhydratedFlexTreeNode.
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { createEmitter } from "@fluid-internal/client-utils";
7
- import type { Listenable } from "@fluidframework/core-interfaces";
7
+ import type { HasListeners, Listenable } from "@fluidframework/core-interfaces/internal";
8
8
  import { assert, oob, fail } from "@fluidframework/core-utils/internal";
9
9
  import { UsageError } from "@fluidframework/telemetry-utils/internal";
10
10
 
@@ -60,7 +60,10 @@ import type { TreeNode } from "./treeNode.js";
60
60
  interface UnhydratedTreeSequenceFieldEditBuilder
61
61
  extends SequenceFieldEditBuilder<FlexibleFieldContent, UnhydratedFlexTreeNode[]> {}
62
62
 
63
- type UnhydratedFlexTreeNodeEvents = Pick<AnchorEvents, "childrenChangedAfterBatch">;
63
+ type UnhydratedFlexTreeNodeEvents = Pick<
64
+ AnchorEvents,
65
+ "childrenChangedAfterBatch" | "subtreeChangedAfterBatch"
66
+ >;
64
67
 
65
68
  /** A node's parent field and its index in that field */
66
69
  type LocationInField = FlexTreeNode["parentField"];
@@ -96,7 +99,8 @@ export class UnhydratedFlexTreeNode
96
99
  public readonly [flexTreeMarker] = FlexTreeEntityKind.Node as const;
97
100
 
98
101
  private readonly _events = createEmitter<UnhydratedFlexTreeNodeEvents>();
99
- public get events(): Listenable<UnhydratedFlexTreeNodeEvents> {
102
+ public get events(): Listenable<UnhydratedFlexTreeNodeEvents> &
103
+ HasListeners<UnhydratedFlexTreeNodeEvents> {
100
104
  return this._events;
101
105
  }
102
106
 
@@ -244,6 +248,25 @@ export class UnhydratedFlexTreeNode
244
248
 
245
249
  public emitChangedEvent(key: FieldKey): void {
246
250
  this._events.emit("childrenChangedAfterBatch", { changedFields: new Set([key]) });
251
+
252
+ // Also emit subtree changed event for this node and all ancestors.
253
+ this.#emitSubtreeChangedEvents();
254
+ }
255
+
256
+ /**
257
+ * Emit subtree changed events for this node and all ancestors.
258
+ */
259
+ #emitSubtreeChangedEvents(): void {
260
+ this._events.emit("subtreeChangedAfterBatch");
261
+
262
+ const parent = this.parentField.parent.parent;
263
+ assert(
264
+ parent === undefined || parent instanceof UnhydratedFlexTreeNode,
265
+ 0xb76 /* Unhydrated node's parent should be an unhydrated node */,
266
+ );
267
+ if (parent !== undefined) {
268
+ parent.#emitSubtreeChangedEvents();
269
+ }
247
270
  }
248
271
  }
249
272
 
@@ -56,6 +56,7 @@ export {
56
56
  walkAllowedTypes,
57
57
  type SchemaVisitor,
58
58
  type SimpleNodeSchemaBase,
59
+ withBufferedTreeEvents,
59
60
  } from "./core/index.js";
60
61
  export { walkFieldSchema } from "./walkFieldSchema.js";
61
62
  export type { UnsafeUnknownSchema, Insertable } from "./unsafeUnknownSchema.js";
@@ -164,6 +165,7 @@ export {
164
165
  type SchemaStaticsAlpha,
165
166
  KeyEncodingOptions,
166
167
  type TreeParsingOptions,
168
+ type SchemaFactory_base,
167
169
  } from "./api/index.js";
168
170
  export type {
169
171
  SimpleTreeSchema,
@@ -49,6 +49,7 @@ import {
49
49
  type FlexContent,
50
50
  type TreeNodeSchemaPrivateData,
51
51
  convertAllowedTypes,
52
+ withBufferedTreeEvents,
52
53
  } from "../../core/index.js";
53
54
  import {
54
55
  type FactoryContent,
@@ -1080,17 +1081,24 @@ abstract class CustomArrayNodeBase<const T extends ImplicitAllowedTypes>
1080
1081
  );
1081
1082
  }
1082
1083
 
1083
- if (sourceField !== destinationField || destinationGap < sourceStart) {
1084
- destinationField.editor.insert(
1085
- destinationGap,
1086
- sourceField.editor.remove(sourceStart, movedCount),
1087
- );
1088
- } else if (destinationGap > sourceStart + movedCount) {
1089
- destinationField.editor.insert(
1090
- destinationGap - movedCount,
1091
- sourceField.editor.remove(sourceStart, movedCount),
1092
- );
1093
- }
1084
+ // We implement move here via subsequent `remove` and `insert`.
1085
+ // This is strictly an implementation detail and should not be observable by the user.
1086
+ // TODO:AB#47457: Implement proper move support for unhydrated trees.
1087
+ // As a temporary mitigation, we will pause tree events until both edits have been completed.
1088
+ // That way, users will only see a single change event for the array instead of 2.
1089
+ withBufferedTreeEvents(() => {
1090
+ if (sourceField !== destinationField || destinationGap < sourceStart) {
1091
+ destinationField.editor.insert(
1092
+ destinationGap,
1093
+ sourceField.editor.remove(sourceStart, movedCount),
1094
+ );
1095
+ } else if (destinationGap > sourceStart + movedCount) {
1096
+ destinationField.editor.insert(
1097
+ destinationGap - movedCount,
1098
+ sourceField.editor.remove(sourceStart, movedCount),
1099
+ );
1100
+ }
1101
+ });
1094
1102
  } else {
1095
1103
  if (!sourceField.context.isHydrated()) {
1096
1104
  throw new UsageError(
@@ -29,6 +29,7 @@ import {
29
29
  type UnannotateImplicitFieldSchema,
30
30
  isArrayNodeSchema,
31
31
  type InsertableField,
32
+ withBufferedTreeEvents,
32
33
  } from "./simple-tree/index.js";
33
34
 
34
35
  // Future improvement TODOs:
@@ -891,16 +892,21 @@ export namespace System_TableSchema {
891
892
  private _applyEditsInBatch(applyEdits: () => void): void {
892
893
  const branch = TreeAlpha.branch(this);
893
894
 
894
- if (branch === undefined) {
895
- // If this node does not have a corresponding branch, then it is unhydrated.
896
- // I.e., it is not part of a collaborative session yet.
897
- // Therefore, we don't need to run the edits as a transaction.
898
- applyEdits();
899
- } else {
900
- branch.runTransaction(() => {
895
+ // Ensure events are paused until all of the edits are applied.
896
+ // This ensures that the user sees the corresponding table-level edit as atomic,
897
+ // and ensures they are not spammed with intermediate events.
898
+ withBufferedTreeEvents(() => {
899
+ if (branch === undefined) {
900
+ // If this node does not have a corresponding branch, then it is unhydrated.
901
+ // I.e., it is not part of a collaborative session yet.
902
+ // Therefore, we don't need to run the edits as a transaction.
901
903
  applyEdits();
902
- });
903
- }
904
+ } else {
905
+ branch.runTransaction(() => {
906
+ applyEdits();
907
+ });
908
+ }
909
+ });
904
910
  }
905
911
 
906
912
  /**