@fluidframework/tree 2.60.0 → 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 (126) hide show
  1. package/.mocharc.cjs +2 -3
  2. package/api-report/tree.alpha.api.md +11 -28
  3. package/api-report/tree.beta.api.md +6 -23
  4. package/api-report/tree.legacy.beta.api.md +71 -22
  5. package/api-report/tree.legacy.public.api.md +5 -22
  6. package/api-report/tree.public.api.md +5 -22
  7. package/dist/alpha.d.ts +10 -5
  8. package/dist/beta.d.ts +8 -4
  9. package/dist/core/tree/anchorSet.d.ts +3 -3
  10. package/dist/core/tree/anchorSet.d.ts.map +1 -1
  11. package/dist/core/tree/anchorSet.js.map +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/legacy.d.ts +22 -4
  16. package/dist/packageVersion.d.ts +1 -1
  17. package/dist/packageVersion.d.ts.map +1 -1
  18. package/dist/packageVersion.js +1 -1
  19. package/dist/packageVersion.js.map +1 -1
  20. package/dist/public.d.ts +6 -3
  21. package/dist/shared-tree/treeCheckout.d.ts.map +1 -1
  22. package/dist/shared-tree/treeCheckout.js +1 -0
  23. package/dist/shared-tree/treeCheckout.js.map +1 -1
  24. package/dist/simple-tree/api/index.d.ts +1 -1
  25. package/dist/simple-tree/api/index.d.ts.map +1 -1
  26. package/dist/simple-tree/api/index.js.map +1 -1
  27. package/dist/simple-tree/api/schemaFactory.d.ts +11 -83
  28. package/dist/simple-tree/api/schemaFactory.d.ts.map +1 -1
  29. package/dist/simple-tree/api/schemaFactory.js +26 -82
  30. package/dist/simple-tree/api/schemaFactory.js.map +1 -1
  31. package/dist/simple-tree/api/schemaFactoryRecursive.d.ts +5 -3
  32. package/dist/simple-tree/api/schemaFactoryRecursive.d.ts.map +1 -1
  33. package/dist/simple-tree/api/schemaFactoryRecursive.js.map +1 -1
  34. package/dist/simple-tree/core/index.d.ts +1 -1
  35. package/dist/simple-tree/core/index.d.ts.map +1 -1
  36. package/dist/simple-tree/core/index.js +2 -1
  37. package/dist/simple-tree/core/index.js.map +1 -1
  38. package/dist/simple-tree/core/treeNodeKernel.d.ts +12 -1
  39. package/dist/simple-tree/core/treeNodeKernel.d.ts.map +1 -1
  40. package/dist/simple-tree/core/treeNodeKernel.js +188 -43
  41. package/dist/simple-tree/core/treeNodeKernel.js.map +1 -1
  42. package/dist/simple-tree/core/unhydratedFlexTree.d.ts +4 -3
  43. package/dist/simple-tree/core/unhydratedFlexTree.d.ts.map +1 -1
  44. package/dist/simple-tree/core/unhydratedFlexTree.js +22 -6
  45. package/dist/simple-tree/core/unhydratedFlexTree.js.map +1 -1
  46. package/dist/simple-tree/index.d.ts +2 -2
  47. package/dist/simple-tree/index.d.ts.map +1 -1
  48. package/dist/simple-tree/index.js +3 -2
  49. package/dist/simple-tree/index.js.map +1 -1
  50. package/dist/simple-tree/node-kinds/array/arrayNode.d.ts.map +1 -1
  51. package/dist/simple-tree/node-kinds/array/arrayNode.js +13 -6
  52. package/dist/simple-tree/node-kinds/array/arrayNode.js.map +1 -1
  53. package/dist/tableSchema.d.ts.map +1 -1
  54. package/dist/tableSchema.js +15 -10
  55. package/dist/tableSchema.js.map +1 -1
  56. package/dist/util/breakable.d.ts.map +1 -1
  57. package/dist/util/breakable.js +7 -1
  58. package/dist/util/breakable.js.map +1 -1
  59. package/lib/alpha.d.ts +10 -5
  60. package/lib/beta.d.ts +8 -4
  61. package/lib/core/tree/anchorSet.d.ts +3 -3
  62. package/lib/core/tree/anchorSet.d.ts.map +1 -1
  63. package/lib/core/tree/anchorSet.js.map +1 -1
  64. package/lib/index.d.ts +1 -1
  65. package/lib/index.d.ts.map +1 -1
  66. package/lib/index.js.map +1 -1
  67. package/lib/legacy.d.ts +22 -4
  68. package/lib/packageVersion.d.ts +1 -1
  69. package/lib/packageVersion.d.ts.map +1 -1
  70. package/lib/packageVersion.js +1 -1
  71. package/lib/packageVersion.js.map +1 -1
  72. package/lib/public.d.ts +6 -3
  73. package/lib/shared-tree/treeCheckout.d.ts.map +1 -1
  74. package/lib/shared-tree/treeCheckout.js +1 -0
  75. package/lib/shared-tree/treeCheckout.js.map +1 -1
  76. package/lib/simple-tree/api/index.d.ts +1 -1
  77. package/lib/simple-tree/api/index.d.ts.map +1 -1
  78. package/lib/simple-tree/api/index.js.map +1 -1
  79. package/lib/simple-tree/api/schemaFactory.d.ts +11 -83
  80. package/lib/simple-tree/api/schemaFactory.d.ts.map +1 -1
  81. package/lib/simple-tree/api/schemaFactory.js +25 -81
  82. package/lib/simple-tree/api/schemaFactory.js.map +1 -1
  83. package/lib/simple-tree/api/schemaFactoryRecursive.d.ts +5 -3
  84. package/lib/simple-tree/api/schemaFactoryRecursive.d.ts.map +1 -1
  85. package/lib/simple-tree/api/schemaFactoryRecursive.js.map +1 -1
  86. package/lib/simple-tree/core/index.d.ts +1 -1
  87. package/lib/simple-tree/core/index.d.ts.map +1 -1
  88. package/lib/simple-tree/core/index.js +1 -1
  89. package/lib/simple-tree/core/index.js.map +1 -1
  90. package/lib/simple-tree/core/treeNodeKernel.d.ts +12 -1
  91. package/lib/simple-tree/core/treeNodeKernel.d.ts.map +1 -1
  92. package/lib/simple-tree/core/treeNodeKernel.js +187 -43
  93. package/lib/simple-tree/core/treeNodeKernel.js.map +1 -1
  94. package/lib/simple-tree/core/unhydratedFlexTree.d.ts +4 -3
  95. package/lib/simple-tree/core/unhydratedFlexTree.d.ts.map +1 -1
  96. package/lib/simple-tree/core/unhydratedFlexTree.js +22 -6
  97. package/lib/simple-tree/core/unhydratedFlexTree.js.map +1 -1
  98. package/lib/simple-tree/index.d.ts +2 -2
  99. package/lib/simple-tree/index.d.ts.map +1 -1
  100. package/lib/simple-tree/index.js +1 -1
  101. package/lib/simple-tree/index.js.map +1 -1
  102. package/lib/simple-tree/node-kinds/array/arrayNode.d.ts.map +1 -1
  103. package/lib/simple-tree/node-kinds/array/arrayNode.js +14 -7
  104. package/lib/simple-tree/node-kinds/array/arrayNode.js.map +1 -1
  105. package/lib/tableSchema.d.ts.map +1 -1
  106. package/lib/tableSchema.js +16 -11
  107. package/lib/tableSchema.js.map +1 -1
  108. package/lib/tsdoc-metadata.json +1 -1
  109. package/lib/util/breakable.d.ts.map +1 -1
  110. package/lib/util/breakable.js +7 -1
  111. package/lib/util/breakable.js.map +1 -1
  112. package/package.json +27 -27
  113. package/src/core/tree/anchorSet.ts +2 -2
  114. package/src/index.ts +1 -0
  115. package/src/packageVersion.ts +1 -1
  116. package/src/shared-tree/treeCheckout.ts +1 -0
  117. package/src/simple-tree/api/index.ts +1 -0
  118. package/src/simple-tree/api/schemaFactory.ts +31 -103
  119. package/src/simple-tree/api/schemaFactoryRecursive.ts +41 -40
  120. package/src/simple-tree/core/index.ts +1 -0
  121. package/src/simple-tree/core/treeNodeKernel.ts +242 -44
  122. package/src/simple-tree/core/unhydratedFlexTree.ts +26 -3
  123. package/src/simple-tree/index.ts +2 -0
  124. package/src/simple-tree/node-kinds/array/arrayNode.ts +19 -11
  125. package/src/tableSchema.ts +15 -9
  126. package/src/util/breakable.ts +9 -1
@@ -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
  /**
@@ -31,9 +31,17 @@ export class Breakable {
31
31
  */
32
32
  public use(): void {
33
33
  if (this.brokenBy !== undefined) {
34
- throw new UsageError(
34
+ const error = new UsageError(
35
35
  `Invalid use of ${this.name} after it was put into an invalid state by another error.\nOriginal Error:\n${this.brokenBy}`,
36
36
  );
37
+
38
+ // This "cause" field is added in ES2022, but using if even without that built in support, it is still helpful.
39
+ // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause
40
+ // TODO: remove this cast when targeting ES2022 lib or later.
41
+ (error as { cause?: unknown }).cause =
42
+ (this.brokenBy as { cause?: unknown }).cause ?? this.brokenBy;
43
+
44
+ throw error;
37
45
  }
38
46
  }
39
47