virtual-tree-canvas 0.7.0 → 0.7.2

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.
@@ -16,18 +16,30 @@ import {
16
16
  TreeSelectionManager,
17
17
  TreeWorkerClient,
18
18
  TreeViewViewport,
19
- VisibleRowModel,
19
+ VisibleRowModel
20
20
  } from './core/index.js';
21
21
  import { numericLayout } from './inspector/numeric-layout.js';
22
22
  import { CellEditorManager } from './inspector/cell-editor-manager.js';
23
- import { formatInspectorValue, getAtPath, inspectorNodeId, inspectorColumns, inspectorPaneColumns, ModelInspectorBuilder, setAtPath } from './inspector/index.js';
23
+ import {
24
+ formatInspectorValue,
25
+ getAtPath,
26
+ inspectorNodeId,
27
+ inspectorColumns,
28
+ inspectorPaneColumns,
29
+ ModelInspectorBuilder,
30
+ setAtPath
31
+ } from './inspector/index.js';
24
32
  import { TreeViewInputController } from './input/tree-view-input-controller.js';
25
33
  import { TreeRowRenderer } from './renderers/index.js';
34
+ import { mergeDynamicStateChanged } from './core/dynamic-state.js';
26
35
 
27
36
  export class TreeViewController {
28
37
  constructor(options = {}) {
29
38
  validateTreeViewOptions(options);
30
- this.layoutOverrides = { rowHeight: options.rowHeight, indentWidth: options.indentWidth };
39
+ this.layoutOverrides = {
40
+ rowHeight: options.rowHeight,
41
+ indentWidth: options.indentWidth
42
+ };
31
43
  this.rawNodes = new Map();
32
44
  this.editable = options.editable !== false;
33
45
  this.destroyed = false;
@@ -38,12 +50,12 @@ export class TreeViewController {
38
50
  model: this.model,
39
51
  expansion: this.expansion,
40
52
  rowHeight: options.rowHeight ?? 28,
41
- indentWidth: options.indentWidth ?? 18,
53
+ indentWidth: options.indentWidth ?? 18
42
54
  });
43
55
  this.viewport = new TreeViewViewport({
44
56
  rowHeight: this.rowModel.rowHeight,
45
57
  indentWidth: this.rowModel.indentWidth,
46
- headerHeight: options.headerHeight ?? 28,
58
+ headerHeight: options.headerHeight ?? 28
47
59
  });
48
60
  this.viewport.renderInsetX = options.renderInsetX ?? 0;
49
61
  this.viewport.renderInsetY = options.renderInsetY ?? 0;
@@ -61,14 +73,29 @@ export class TreeViewController {
61
73
  this.searchIndex = new TreeSearchIndex();
62
74
  this.selection = new TreeSelectionManager();
63
75
  this.patchBatcher = new PatchBatcher();
76
+ this.dynamicPatchQueue = new PatchBatcher();
77
+ this.invalidation = { full: true, dirtyNodeIds: new Set() };
78
+ this.renderForced = false;
64
79
  this.themeManager = options.themeManager ?? new ThemeManager(options.theme);
65
- this.iconRegistry = options.iconRegistry ?? new IconRegistry({ iconsBaseUrl: options.iconsBaseUrl });
66
- this.renderer = options.renderer ?? new TreeRowRenderer({ themeManager: this.themeManager, iconRegistry: this.iconRegistry });
80
+ this.iconRegistry =
81
+ options.iconRegistry ??
82
+ new IconRegistry({
83
+ iconsBaseUrl: options.iconsBaseUrl
84
+ });
85
+ this.renderer =
86
+ options.renderer ??
87
+ new TreeRowRenderer({
88
+ themeManager: this.themeManager,
89
+ iconRegistry: this.iconRegistry
90
+ });
67
91
  this.nativeScrollbars = options.nativeScrollbars !== false;
68
92
  this.initialExpandDepth = options.initialExpandDepth ?? 1;
69
93
  this.searchHighlights = new Set();
70
94
  this.filterQuery = '';
71
- this.filterOptions = { caseSensitive: false, wholeWord: false };
95
+ this.filterOptions = {
96
+ caseSensitive: false,
97
+ wholeWord: false
98
+ };
72
99
  this.hoverId = null;
73
100
  this.hoverPart = null;
74
101
  this.activeId = null;
@@ -79,6 +106,16 @@ export class TreeViewController {
79
106
  this.lastDirtyNodeCount = 0;
80
107
  this.lastRenderedRows = 0;
81
108
  this.rebuildCount = 0;
109
+ this.performanceStats = {
110
+ setDynamicStateCalls: 0,
111
+ patchesReceived: 0,
112
+ uniqueNodesReceived: 0,
113
+ nodesChanged: 0,
114
+ rendersRequested: 0,
115
+ rendersExecuted: 0,
116
+ rendersAvoidedNoChanges: 0,
117
+ rendersAvoidedOffscreen: 0
118
+ };
82
119
  this.workerClient = null;
83
120
  this.workerRevision = 0;
84
121
  this.sortValueSnapshot = null;
@@ -87,15 +124,36 @@ export class TreeViewController {
87
124
  this.cellEditor = null;
88
125
  const initialTheme = this.themeManager.get();
89
126
  this.rowModel.rowHeight = this.viewport.rowHeight = options.rowHeight ?? initialTheme.rowHeight;
90
- this.rowModel.indentWidth = this.viewport.indentWidth = options.indentWidth ?? initialTheme.indentWidth;
127
+ this.rowModel.indentWidth = this.viewport.indentWidth =
128
+ options.indentWidth ?? initialTheme.indentWidth;
91
129
  this.scene = this.createRenderScene();
92
130
  this.renderer.setInvalidationHandler?.(() => this.requestRender(true));
93
131
 
94
- for (const type of ['viewportchange', 'columnschange', 'sortchange', 'filterchange', 'themechange', 'layoutchange', 'expand', 'collapse', 'searchchange', 'nodehover', 'selectionchange', 'focuschange', 'rowreorder', 'rowdrag', 'rowreorderchange', 'modelchange']) {
132
+ for (const type of [
133
+ 'viewportchange',
134
+ 'columnschange',
135
+ 'sortchange',
136
+ 'filterchange',
137
+ 'themechange',
138
+ 'layoutchange',
139
+ 'expand',
140
+ 'collapse',
141
+ 'searchchange',
142
+ 'nodehover',
143
+ 'selectionchange',
144
+ 'focuschange',
145
+ 'rowreorder',
146
+ 'rowdrag',
147
+ 'rowreorderchange',
148
+ 'modelchange'
149
+ ]) {
95
150
  this.events.on(type, () => this.requestRender());
96
151
  }
97
152
  if (options.canvas) this.initialize(options.canvas);
98
- if (options.canvas && (options.host || options.editable)) this.attachCellEditor({ host: options.host });
153
+ if (options.canvas && (options.host || options.editable))
154
+ this.attachCellEditor({
155
+ host: options.host
156
+ });
99
157
  if (options.canvas && options.input !== false) this.attachInput();
100
158
  }
101
159
 
@@ -107,21 +165,26 @@ export class TreeViewController {
107
165
  this.renderer.initialize(canvas);
108
166
  this.renderer.setScene(this.scene);
109
167
  this.actionOverlay?.destroy();
110
- this.actionOverlay = canvas.ownerDocument?.createElement && canvas.parentElement ? new RowActions(this) : null;
168
+ this.actionOverlay =
169
+ canvas.ownerDocument?.createElement && canvas.parentElement ? new RowActions(this) : null;
111
170
  this.#resizeToCanvasClientSize();
112
171
  this.#observeCanvasSize();
113
172
  this.#setupNativeScrollbars();
114
- if (this.tooltipEnabled) this.attachTooltip({ host: this.host });
173
+ if (this.tooltipEnabled)
174
+ this.attachTooltip({
175
+ host: this.host
176
+ });
115
177
  this.requestRender();
116
178
  return this;
117
179
  }
118
180
 
119
181
  attachCellEditor(options = {}) {
120
- if (!this.canvas) throw new Error('TreeViewController.attachCellEditor requires an initialized canvas');
182
+ if (!this.canvas)
183
+ throw new Error('TreeViewController.attachCellEditor requires an initialized canvas');
121
184
  this.cellEditor?.destroy?.();
122
185
  this.cellEditor = new CellEditorManager({
123
186
  controller: this,
124
- host: options.host,
187
+ host: options.host
125
188
  });
126
189
  this.cellEditor.setEditable(this.editable);
127
190
  if (this.inputController) this.inputController.cellEditor = this.cellEditor;
@@ -129,7 +192,9 @@ export class TreeViewController {
129
192
  }
130
193
 
131
194
  setRowActions(actions) {
132
- validateTreeViewOptions({ rowActions: actions });
195
+ validateTreeViewOptions({
196
+ rowActions: actions
197
+ });
133
198
  this.rowActions = actions;
134
199
  this.columnModel.setRowActions(actions.length);
135
200
  this.columnModel.setRowReorder(this.rowReorder && !this.inspector);
@@ -138,36 +203,47 @@ export class TreeViewController {
138
203
  }
139
204
 
140
205
  setRowDrag(resolver) {
141
- validateTreeViewOptions({ rowDrag: resolver });
206
+ validateTreeViewOptions({
207
+ rowDrag: resolver
208
+ });
142
209
  this.rowReorderInput?.cancel();
143
210
  this.rowDrag = resolver;
144
211
  this.rowReorderInput?.syncMode();
145
212
  }
146
213
 
147
214
  setEditable(enabled) {
148
- validateTreeViewOptions({ editable: enabled });
215
+ validateTreeViewOptions({
216
+ editable: enabled
217
+ });
149
218
  if (this.editable === enabled) return;
150
219
  this.editable = enabled;
151
- if (!this.cellEditor && this.canvas) this.attachCellEditor({ host: this.host });
220
+ if (!this.cellEditor && this.canvas)
221
+ this.attachCellEditor({
222
+ host: this.host
223
+ });
152
224
  this.cellEditor?.setEditable(enabled);
153
- this.events.emit('editablechange', { editable: enabled });
225
+ this.events.emit('editablechange', {
226
+ editable: enabled
227
+ });
154
228
  this.requestRender();
155
229
  }
156
230
 
157
231
  setInitialExpandDepth(depth) {
158
- validateTreeViewOptions({ initialExpandDepth: depth });
232
+ validateTreeViewOptions({
233
+ initialExpandDepth: depth
234
+ });
159
235
  this.initialExpandDepth = depth;
160
236
  }
161
237
 
162
-
163
238
  attachInput(options = {}) {
164
- if (!this.canvas) throw new Error('TreeViewController.attachInput requires an initialized canvas');
239
+ if (!this.canvas)
240
+ throw new Error('TreeViewController.attachInput requires an initialized canvas');
165
241
  this.inputController?.destroy?.();
166
242
  this.rowReorderInput?.destroy();
167
243
  this.rowReorderInput = new RowReorderInput(this);
168
244
  this.inputController = new TreeViewInputController({
169
245
  controller: this,
170
- cellEditor: options.cellEditor ?? this.cellEditor,
246
+ cellEditor: options.cellEditor ?? this.cellEditor
171
247
  });
172
248
  return this.inputController;
173
249
  }
@@ -180,6 +256,10 @@ export class TreeViewController {
180
256
  this.tooltip?.destroy();
181
257
  this.tooltip = null;
182
258
  this.cancelRender();
259
+ this.patchBatcher.clear();
260
+ this.dynamicPatchQueue.clear();
261
+ this.invalidation.dirtyNodeIds.clear();
262
+ this.invalidation.full = false;
183
263
  this.inputController?.destroy?.();
184
264
  this.cellEditor?.destroy?.();
185
265
  this.disableWorkers();
@@ -203,16 +283,19 @@ export class TreeViewController {
203
283
  }
204
284
 
205
285
  setData(nodes, options = {}) {
286
+ this.flushDynamicState();
206
287
  if (!Array.isArray(nodes)) throw new TypeError('nodes must be an array');
207
- validateTreeViewOptions({ iconResolver: options.iconResolver ?? null });
288
+ validateTreeViewOptions({
289
+ iconResolver: options.iconResolver ?? null
290
+ });
208
291
  const resolved = resolveNodeIcons(nodes, options.iconResolver);
209
292
  this.closeEditor();
210
- this.rawNodes = new Map(nodes.map(node => [node.id, node]));
293
+ this.rawNodes = new Map(nodes.map((node) => [node.id, node]));
211
294
  nodes = resolved;
212
295
  const preserveExpansion = !this.inspector;
213
296
  this.inspector = null;
214
297
  this.columnModel.setRowReorder(this.rowReorder);
215
- if (this.columnModel.columns.some(column => column.kind?.startsWith('inspector'))) {
298
+ if (this.columnModel.columns.some((column) => column.kind?.startsWith('inspector'))) {
216
299
  this.columnModel.setColumns([]);
217
300
  }
218
301
  this.#replaceTree(nodes, preserveExpansion);
@@ -223,7 +306,11 @@ export class TreeViewController {
223
306
  }
224
307
 
225
308
  #replaceTree(nodes, preserveExpansion) {
226
- const branches = preserveExpansion ? this.model.nodes.filter(node => this.expansion.hasChildren(node.id)).map(node => node.id) : [];
309
+ const branches = preserveExpansion
310
+ ? this.model.nodes
311
+ .filter((node) => this.expansion.hasChildren(node.id))
312
+ .map((node) => node.id)
313
+ : [];
227
314
  const expanded = new Set(this.model.expanded);
228
315
  this.model.setTree(nodes);
229
316
  this.expansion.expandToDepth(this.initialExpandDepth);
@@ -235,9 +322,11 @@ export class TreeViewController {
235
322
  }
236
323
 
237
324
  setIconResolver(resolver) {
238
- validateTreeViewOptions({ iconResolver: resolver });
325
+ validateTreeViewOptions({
326
+ iconResolver: resolver
327
+ });
239
328
  if (this.inspector) return;
240
- const source = this.model.nodes.map(node => this.rawNodes.get(node.id) ?? node);
329
+ const source = this.model.nodes.map((node) => this.rawNodes.get(node.id) ?? node);
241
330
  const nodes = resolveNodeIcons(source, resolver);
242
331
  this.model.nodes = nodes;
243
332
  this.model.index.rebuild(nodes);
@@ -262,17 +351,26 @@ export class TreeViewController {
262
351
  }
263
352
 
264
353
  setRowReorder(enabled) {
265
- validateTreeViewOptions({ rowReorder: enabled });
354
+ validateTreeViewOptions({
355
+ rowReorder: enabled
356
+ });
266
357
  this.rowReorder = Boolean(enabled);
267
358
  this.rowDrop = null;
268
359
  this.columnModel.setRowReorder(this.rowReorder && !this.inspector);
269
360
  this.#syncContentSize();
270
- this.events.emit('rowreorderchange', { enabled: this.rowReorder });
361
+ this.events.emit('rowreorderchange', {
362
+ enabled: this.rowReorder
363
+ });
271
364
  }
272
365
 
273
366
  canReorderRows(nodeId) {
274
- return this.rowReorder && !this.inspector && !this.columnModel.sort.direction && !this.rowModel.filterPredicate
275
- && (nodeId == null || this.model.index.getNode(nodeId)?.reorderable !== false);
367
+ return (
368
+ this.rowReorder &&
369
+ !this.inspector &&
370
+ !this.columnModel.sort.direction &&
371
+ !this.rowModel.filterPredicate &&
372
+ (nodeId == null || this.model.index.getNode(nodeId)?.reorderable !== false)
373
+ );
276
374
  }
277
375
 
278
376
  getRowOrder(parentId = null) {
@@ -280,6 +378,7 @@ export class TreeViewController {
280
378
  }
281
379
 
282
380
  moveRow(nodeId, targetIndex, options = {}) {
381
+ this.flushDynamicState();
283
382
  if (!this.canReorderRows(nodeId)) return false;
284
383
  const anchorId = this.rowModel.getRow(this.anchorRowIndex)?.nodeId;
285
384
  const detail = this.model.moveNode(nodeId, targetIndex);
@@ -293,8 +392,13 @@ export class TreeViewController {
293
392
  this.selection.focused = nodeId;
294
393
  this.cellEditor?.close?.();
295
394
  this.scrollToNode(nodeId);
296
- this.events.emit('rowreorder', { ...detail, source: options.source ?? 'api' });
297
- this.events.emit('focuschange', { nodeId });
395
+ this.events.emit('rowreorder', {
396
+ ...detail,
397
+ source: options.source ?? 'api'
398
+ });
399
+ this.events.emit('focuschange', {
400
+ nodeId
401
+ });
298
402
  return true;
299
403
  }
300
404
 
@@ -309,10 +413,17 @@ export class TreeViewController {
309
413
  if (!this.canReorderRows(nodeId)) return null;
310
414
  const x = clientX - this.viewport.renderInsetX;
311
415
  const y = clientY - this.viewport.renderInsetY;
312
- if (x < 0 || x >= this.viewport.contentViewportWidth || y < this.viewport.headerHeight
313
- || y >= this.viewport.headerHeight + this.viewport.rowViewportHeight) return null;
416
+ if (
417
+ x < 0 ||
418
+ x >= this.viewport.contentViewportWidth ||
419
+ y < this.viewport.headerHeight ||
420
+ y >= this.viewport.headerHeight + this.viewport.rowViewportHeight
421
+ )
422
+ return null;
314
423
  const contentY = y - this.viewport.headerHeight + this.viewport.scrollY;
315
- const row = this.rowModel.getRow(Math.min(this.rowModel.rows.length - 1, Math.floor(contentY / this.rowModel.rowHeight)));
424
+ const row = this.rowModel.getRow(
425
+ Math.min(this.rowModel.rows.length - 1, Math.floor(contentY / this.rowModel.rowHeight))
426
+ );
316
427
  const source = this.model.index.getNode(nodeId);
317
428
  const target = row && this.model.index.getNode(row.nodeId);
318
429
  if (!source || !target || (source.parentId ?? null) !== (target.parentId ?? null)) return null;
@@ -321,21 +432,46 @@ export class TreeViewController {
321
432
  const from = siblings.indexOf(nodeId);
322
433
  let index = siblings.indexOf(target.id) + Number(after);
323
434
  if (index > from) index--;
324
- return index === from ? null : { nodeId, targetId: target.id, index, y: row.y + (after ? row.height : 0) };
435
+ return index === from
436
+ ? null
437
+ : {
438
+ nodeId,
439
+ targetId: target.id,
440
+ index,
441
+ y: row.y + (after ? row.height : 0)
442
+ };
325
443
  }
326
444
 
327
445
  setRowDrop(target) {
328
446
  this.rowDrop = target;
329
- this.events.emit('rowdrag', { target });
447
+ this.events.emit('rowdrag', {
448
+ target
449
+ });
330
450
  }
331
451
 
332
452
  requestRender(force = false) {
333
- if (this.destroyed || (!this.autoRender && !force) || !this.canvas || this.renderFrame !== null) return;
334
- const view = this.canvas.ownerDocument?.defaultView ?? globalThis;
335
- const schedule = view.requestAnimationFrame?.bind(view) ?? (callback => setTimeout(callback, 0));
453
+ if (this.destroyed) return;
454
+ this.invalidation.full = true;
455
+ this.invalidation.dirtyNodeIds.clear();
456
+ if ((!this.autoRender && !force) || !this.canvas) return;
457
+ if (this.renderFrame === null) this.performanceStats.rendersRequested++;
458
+ this.renderForced ||= force;
459
+ this.#scheduleFrame();
460
+ }
461
+
462
+ #scheduleFrame() {
463
+ if (this.destroyed || this.renderFrame !== null) return;
464
+ const view = this.canvas?.ownerDocument?.defaultView ?? globalThis;
465
+ const schedule =
466
+ view.requestAnimationFrame?.bind(view) ?? ((callback) => setTimeout(callback, 0));
336
467
  this.renderFrame = schedule(() => {
337
468
  this.renderFrame = null;
338
- if (this.canvas) this.render();
469
+ const forced = this.renderForced;
470
+ this.renderForced = false;
471
+ this.flushDynamicState();
472
+ if (this.canvas && (this.autoRender || forced) && this.#hasInvalidation()) {
473
+ this.render();
474
+ }
339
475
  });
340
476
  }
341
477
 
@@ -344,16 +480,27 @@ export class TreeViewController {
344
480
  const view = this.canvas?.ownerDocument?.defaultView ?? globalThis;
345
481
  (view.cancelAnimationFrame?.bind(view) ?? clearTimeout)(this.renderFrame);
346
482
  this.renderFrame = null;
483
+ this.renderForced = false;
347
484
  }
348
485
 
349
486
  attachTooltip(options = {}) {
350
487
  this.tooltip?.destroy();
351
- this.tooltip = new TreeTooltip({ controller: this, host: options.host ?? this.canvas?.parentElement });
488
+ this.tooltip = new TreeTooltip({
489
+ controller: this,
490
+ host: options.host ?? this.canvas?.parentElement
491
+ });
352
492
  return this.tooltip;
353
493
  }
354
494
 
355
495
  setModel(model, meta = {}, options = {}) {
356
- validateTreeViewOptions({ ...options, mode: 'inspector', model, meta, presentation: options.presentation ?? options.mode ?? 'table' });
496
+ this.flushDynamicState();
497
+ validateTreeViewOptions({
498
+ ...options,
499
+ mode: 'inspector',
500
+ model,
501
+ meta,
502
+ presentation: options.presentation ?? options.mode ?? 'table'
503
+ });
357
504
  this.columnModel.setRowReorder(false);
358
505
  const builder = new ModelInspectorBuilder();
359
506
  const presentation = options.presentation ?? options.mode ?? 'table';
@@ -363,21 +510,34 @@ export class TreeViewController {
363
510
  flatRoot: Boolean(options.flatRoot),
364
511
  enforceMeta: Boolean(options.enforceMeta),
365
512
  filter: Boolean(options.filter),
366
- markUpdated: options.markUpdated !== false,
513
+ markUpdated: options.markUpdated !== false
367
514
  };
368
515
  const nodes = builder.build(model, meta, inspectorOptions);
369
- const sameLayout = this.inspector?.presentation === presentation &&
370
- nodes.length === this.model.nodes.length && nodes.every((node, index) => node.id === this.model.nodes[index].id);
516
+ const sameLayout =
517
+ this.inspector?.presentation === presentation &&
518
+ nodes.length === this.model.nodes.length &&
519
+ nodes.every((node, index) => node.id === this.model.nodes[index].id);
371
520
  if (!sameLayout) this.closeEditor();
372
521
  else this.cellEditor?.reconcile(nodes);
373
522
  this.rawNodes.clear();
374
- this.inspector = { model, meta, builder, presentation, options: inspectorOptions };
375
- if (!sameLayout) this.setColumns(presentation === 'pane' ? inspectorPaneColumns() : inspectorColumns());
523
+ this.inspector = {
524
+ model,
525
+ meta,
526
+ builder,
527
+ presentation,
528
+ options: inspectorOptions
529
+ };
530
+ if (!sameLayout)
531
+ this.setColumns(presentation === 'pane' ? inspectorPaneColumns() : inspectorColumns());
376
532
  this.#replaceTree(nodes, preserveExpansion);
377
533
  this.searchIndex.rebuild(this.model);
378
534
  this.#rebuildRows();
379
535
  this.events.emit('datachange', {});
380
- this.events.emit('modelchange', { model, meta, structural: true });
536
+ this.events.emit('modelchange', {
537
+ model,
538
+ meta,
539
+ structural: true
540
+ });
381
541
  }
382
542
 
383
543
  updateInspectorValue(nodeId, newValue, editorType = 'unknown', options = {}) {
@@ -389,15 +549,39 @@ export class TreeViewController {
389
549
  if (Object.is(oldValue, newValue)) return true;
390
550
  setAtPath(this.inspector.model, data.path, newValue);
391
551
  data.value = newValue;
392
- data.valueType = Array.isArray(newValue) ? 'array' : newValue === null ? 'null' : typeof newValue === 'object' ? 'object' : typeof newValue;
552
+ data.valueType = Array.isArray(newValue)
553
+ ? 'array'
554
+ : newValue === null
555
+ ? 'null'
556
+ : typeof newValue === 'object'
557
+ ? 'object'
558
+ : typeof newValue;
393
559
  data.valueText = formatInspectorValue(newValue, data.meta);
394
560
  if (this.inspector.options.markUpdated !== false) {
395
- this.setDynamicState([{ id: nodeId, state: { updated: true } }]);
561
+ this.setDynamicState([
562
+ {
563
+ id: nodeId,
564
+ state: {
565
+ updated: true
566
+ }
567
+ }
568
+ ]);
569
+ this.flushDynamicState();
396
570
  }
397
- const detail = { path: data.path, oldValue, newValue, nodeId, editorType, model: this.inspector.model, source: options.source ?? 'user' };
571
+ const detail = {
572
+ path: data.path,
573
+ oldValue,
574
+ newValue,
575
+ nodeId,
576
+ editorType,
577
+ model: this.inspector.model,
578
+ source: options.source ?? 'user'
579
+ };
398
580
  if (options.emit !== false) {
399
581
  this.events.emit('valuechange', detail);
400
- this.events.emit('modelchange', { ...detail });
582
+ this.events.emit('modelchange', {
583
+ ...detail
584
+ });
401
585
  }
402
586
  this.requestRender();
403
587
  return true;
@@ -405,14 +589,23 @@ export class TreeViewController {
405
589
 
406
590
  setInspectorValue(path, value, options = {}) {
407
591
  if (typeof path !== 'string') throw new TypeError('path must be a string');
408
- return this.updateInspectorValue(inspectorNodeId(path === '$' ? '' : path), value, 'api', { ...options, source: 'api' });
592
+ return this.updateInspectorValue(inspectorNodeId(path === '$' ? '' : path), value, 'api', {
593
+ ...options,
594
+ source: 'api'
595
+ });
409
596
  }
410
597
 
411
598
  triggerInspectorAction(nodeId) {
412
599
  const node = this.model.index.getNode(nodeId);
413
600
  if (!node?.data?.inspector) return false;
414
601
  const label = node.data.meta?.button ?? node.label;
415
- this.events.emit('action', { path: node.data.path, label, nodeId, model: this.inspector?.model, source: 'user' });
602
+ this.events.emit('action', {
603
+ path: node.data.path,
604
+ label,
605
+ nodeId,
606
+ model: this.inspector?.model,
607
+ source: 'user'
608
+ });
416
609
  return true;
417
610
  }
418
611
 
@@ -424,7 +617,13 @@ export class TreeViewController {
424
617
  const item = createDefaultArrayItem(node.data.meta);
425
618
  array.push(item);
426
619
  this.#rebuildInspectorModel(node.data.path);
427
- this.events.emit('modelchange', { model: this.inspector.model, path: node.data.path, structural: true, action: 'array-add', value: item });
620
+ this.events.emit('modelchange', {
621
+ model: this.inspector.model,
622
+ path: node.data.path,
623
+ structural: true,
624
+ action: 'array-add',
625
+ value: item
626
+ });
428
627
  return true;
429
628
  }
430
629
 
@@ -435,7 +634,13 @@ export class TreeViewController {
435
634
  if (!Array.isArray(array) || !array.length) return false;
436
635
  const value = array.pop();
437
636
  this.#rebuildInspectorModel(node.data.path);
438
- this.events.emit('modelchange', { model: this.inspector.model, path: node.data.path, structural: true, action: 'array-remove', value });
637
+ this.events.emit('modelchange', {
638
+ model: this.inspector.model,
639
+ path: node.data.path,
640
+ structural: true,
641
+ action: 'array-remove',
642
+ value
643
+ });
439
644
  return true;
440
645
  }
441
646
 
@@ -443,7 +648,8 @@ export class TreeViewController {
443
648
  if (this.workerClient) return Promise.resolve(this);
444
649
  const client = new TreeWorkerClient(workerUrl);
445
650
  this.workerClient = client;
446
- return client.setData(this.model.nodes)
651
+ return client
652
+ .setData(this.model.nodes)
447
653
  .then(() => this)
448
654
  .catch((error) => {
449
655
  if (this.workerClient === client) this.disableWorkers();
@@ -457,30 +663,42 @@ export class TreeViewController {
457
663
  }
458
664
 
459
665
  setColumns(columns) {
460
- if (columns == null) columns = this.inspector ? (this.inspector.presentation === 'pane' ? inspectorPaneColumns() : inspectorColumns()) : [];
666
+ if (columns == null)
667
+ columns = this.inspector
668
+ ? this.inspector.presentation === 'pane'
669
+ ? inspectorPaneColumns()
670
+ : inspectorColumns()
671
+ : [];
461
672
  if (!Array.isArray(columns)) throw new TypeError('columns must be an array or null');
462
673
  this.closeEditor();
463
674
  this.columnModel.setColumns(columns);
464
675
  this.columnModel.setRowReorder(this.rowReorder && !this.inspector);
465
676
  this.#syncContentSize();
466
- this.events.emit('columnschange', { columns: this.columnModel.columns });
677
+ this.events.emit('columnschange', {
678
+ columns: this.columnModel.columns
679
+ });
467
680
  }
468
681
 
469
682
  resizeColumn(columnId, width) {
470
683
  if (!this.columnModel.resizeColumn(columnId, width)) return false;
471
684
  this.#syncContentSize();
472
- this.events.emit('columnschange', { columns: this.columnModel.columns });
685
+ this.events.emit('columnschange', {
686
+ columns: this.columnModel.columns
687
+ });
473
688
  return true;
474
689
  }
475
690
 
476
691
  moveColumn(columnId, targetIndex) {
477
692
  if (!this.columnModel.moveColumn(columnId, targetIndex)) return false;
478
693
  this.#syncContentSize();
479
- this.events.emit('columnschange', { columns: this.columnModel.columns });
694
+ this.events.emit('columnschange', {
695
+ columns: this.columnModel.columns
696
+ });
480
697
  return true;
481
698
  }
482
699
 
483
700
  sortBy(columnId, direction = 'toggle') {
701
+ this.flushDynamicState();
484
702
  const column = this.columnModel.getColumn(columnId);
485
703
  if (!column || column.sortable === false) return false;
486
704
  const current = this.columnModel.sort;
@@ -496,10 +714,21 @@ export class TreeViewController {
496
714
  return true;
497
715
  }
498
716
  this.columnModel.setSort(columnId, nextDirection);
499
- this.sortValueSnapshot = createSortValueSnapshot(column, this.model.nodes, this.model.dynamicState);
500
- this.rowModel.setSortComparator((a, b) => compareColumnValues(column, a, b, this.model.dynamicState, this.sortValueSnapshot) * (nextDirection === 'desc' ? -1 : 1));
717
+ this.sortValueSnapshot = createSortValueSnapshot(
718
+ column,
719
+ this.model.nodes,
720
+ this.model.dynamicState
721
+ );
722
+ this.rowModel.setSortComparator(
723
+ (a, b) =>
724
+ compareColumnValues(column, a, b, this.model.dynamicState, this.sortValueSnapshot) *
725
+ (nextDirection === 'desc' ? -1 : 1)
726
+ );
501
727
  this.#rebuildRows();
502
- this.events.emit('sortchange', { columnId, direction: nextDirection });
728
+ this.events.emit('sortchange', {
729
+ columnId,
730
+ direction: nextDirection
731
+ });
503
732
  return true;
504
733
  }
505
734
 
@@ -508,24 +737,51 @@ export class TreeViewController {
508
737
  this.sortValueSnapshot = null;
509
738
  this.rowModel.setSortComparator(null);
510
739
  this.#rebuildRows();
511
- this.events.emit('sortchange', { columnId: null, direction: null });
740
+ this.events.emit('sortchange', {
741
+ columnId: null,
742
+ direction: null
743
+ });
512
744
  }
513
745
 
514
746
  setFilter(queryOrPredicate = '', options = {}) {
515
- if (typeof queryOrPredicate !== 'string' && typeof queryOrPredicate !== 'function') throw new TypeError('filter must be a string or predicate');
747
+ this.flushDynamicState();
748
+ if (typeof queryOrPredicate !== 'string' && typeof queryOrPredicate !== 'function')
749
+ throw new TypeError('filter must be a string or predicate');
516
750
  if (this.cellEditor?.overlayKind !== 'filter') this.closeEditor();
517
751
  this.filterQuery = typeof queryOrPredicate === 'string' ? queryOrPredicate : '';
518
- this.filterOptions = typeof queryOrPredicate === 'string'
519
- ? { caseSensitive: Boolean(options.caseSensitive), wholeWord: Boolean(options.wholeWord) }
520
- : { caseSensitive: false, wholeWord: false };
752
+ this.filterOptions =
753
+ typeof queryOrPredicate === 'string'
754
+ ? {
755
+ caseSensitive: Boolean(options.caseSensitive),
756
+ wholeWord: Boolean(options.wholeWord)
757
+ }
758
+ : {
759
+ caseSensitive: false,
760
+ wholeWord: false
761
+ };
521
762
  if (typeof queryOrPredicate === 'function') {
522
763
  this.rowModel.setFilterPredicate(queryOrPredicate);
523
764
  } else {
524
765
  const query = normalizeFilterValue(queryOrPredicate.trim(), this.filterOptions);
525
- this.rowModel.setFilterPredicate(query ? (node, state) => matchesFilter(node, state, this.model.index.pathById.get(node.id) ?? '', query, this.filterOptions) : null);
766
+ this.rowModel.setFilterPredicate(
767
+ query
768
+ ? (node, state) =>
769
+ matchesFilter(
770
+ node,
771
+ state,
772
+ this.model.index.pathById.get(node.id) ?? '',
773
+ query,
774
+ this.filterOptions
775
+ )
776
+ : null
777
+ );
526
778
  }
527
779
  this.#rebuildRows();
528
- this.events.emit('filterchange', { query: this.filterQuery, options: this.filterOptions, visibleRows: this.rowModel.rows.length });
780
+ this.events.emit('filterchange', {
781
+ query: this.filterQuery,
782
+ options: this.filterOptions,
783
+ visibleRows: this.rowModel.rows.length
784
+ });
529
785
  }
530
786
 
531
787
  clearFilter() {
@@ -540,45 +796,128 @@ export class TreeViewController {
540
796
  const totalStart = now();
541
797
  const revision = ++this.workerRevision;
542
798
  this.filterQuery = query;
543
- this.filterOptions = { caseSensitive: Boolean(options.caseSensitive), wholeWord: Boolean(options.wholeWord) };
799
+ this.filterOptions = {
800
+ caseSensitive: Boolean(options.caseSensitive),
801
+ wholeWord: Boolean(options.wholeWord)
802
+ };
544
803
  const normalized = normalizeFilterValue(query.trim(), this.filterOptions);
545
- this.rowModel.setFilterPredicate(normalized ? (node, state) => matchesFilter(node, state, this.model.index.pathById.get(node.id) ?? '', normalized, this.filterOptions) : null);
804
+ this.rowModel.setFilterPredicate(
805
+ normalized
806
+ ? (node, state) =>
807
+ matchesFilter(
808
+ node,
809
+ state,
810
+ this.model.index.pathById.get(node.id) ?? '',
811
+ normalized,
812
+ this.filterOptions
813
+ )
814
+ : null
815
+ );
546
816
  const workerStart = now();
547
- const result = await this.workerClient.rebuildRows(this.#workerRowOptions({ filterQuery: query, filterOptions: this.filterOptions }));
817
+ const result = await this.workerClient.rebuildRows(
818
+ this.#workerRowOptions({
819
+ filterQuery: query,
820
+ filterOptions: this.filterOptions
821
+ })
822
+ );
548
823
  const workerMs = now() - workerStart;
549
824
  if (revision !== this.workerRevision) return this.rowModel.rows;
550
825
  this.#applyWorkerRows(result);
551
- this.events.emit('filterchange', { query: this.filterQuery, options: this.filterOptions, visibleRows: this.rowModel.rows.length, worker: true, workerMs, totalMs: now() - totalStart });
826
+ this.events.emit('filterchange', {
827
+ query: this.filterQuery,
828
+ options: this.filterOptions,
829
+ visibleRows: this.rowModel.rows.length,
830
+ worker: true,
831
+ workerMs,
832
+ totalMs: now() - totalStart
833
+ });
552
834
  return this.rowModel.rows;
553
835
  }
554
836
 
555
837
  setDynamicState(patches) {
556
838
  if (!Array.isArray(patches)) throw new TypeError('patches must be an array');
557
- this.lastPatchCount = patches.length;
558
- this.lastDirtyNodeCount = new Set(patches.map((patch) => patch.id)).size;
559
- this.model.applyDynamicPatches(patches);
560
- this.renderer.updateDynamicState(patches);
561
- this.requestRender();
839
+ if (this.destroyed || patches.length === 0) return;
840
+ this.performanceStats.setDynamicStateCalls++;
841
+ this.performanceStats.patchesReceived += patches.length;
842
+ this.lastPatchCount += patches.length;
843
+ this.dynamicPatchQueue.addMany(patches);
844
+ this.#scheduleFrame();
845
+ }
846
+
847
+ /**
848
+ * Applies queued dynamic patches immediately. Rendering, scene creation,
849
+ * sorting and filtering call this method before reading dynamic state.
850
+ * @returns {Set<string>} IDs whose stored state actually changed.
851
+ */
852
+ flushDynamicState() {
853
+ if (!this.dynamicPatchQueue.size) return new Set();
854
+ const hadInvalidation = this.#hasInvalidation();
855
+ const queued = this.dynamicPatchQueue.pending;
856
+ const changedIds = new Set();
857
+ const changedPatches = [];
858
+ this.performanceStats.uniqueNodesReceived += queued.size;
859
+ this.lastDirtyNodeCount = 0;
860
+
861
+ for (const [id, state] of queued) {
862
+ const current = this.model.dynamicState.get(id);
863
+ if (!current || !mergeDynamicStateChanged(current, state)) continue;
864
+ changedIds.add(id);
865
+ changedPatches.push({ id, state });
866
+ }
867
+ queued.clear();
868
+
869
+ if (!changedIds.size) {
870
+ if (!hadInvalidation) this.performanceStats.rendersAvoidedNoChanges++;
871
+ this.lastPatchCount = 0;
872
+ return changedIds;
873
+ }
874
+
875
+ this.lastDirtyNodeCount = changedIds.size;
876
+ this.performanceStats.nodesChanged += changedIds.size;
877
+ this.renderer.updateDynamicState(changedPatches);
878
+ if (this.#hasVisibleDirtyNode(changedIds)) {
879
+ if (!this.invalidation.full) {
880
+ const requestsRender = this.invalidation.dirtyNodeIds.size === 0;
881
+ for (const id of changedIds) this.invalidation.dirtyNodeIds.add(id);
882
+ if (requestsRender) this.performanceStats.rendersRequested++;
883
+ }
884
+ } else {
885
+ if (!hadInvalidation) this.performanceStats.rendersAvoidedOffscreen++;
886
+ this.lastPatchCount = 0;
887
+ }
888
+ return changedIds;
562
889
  }
563
890
 
564
891
  setTheme(theme) {
892
+ this.flushDynamicState();
565
893
  const beforeRowHeight = this.rowModel.rowHeight;
566
894
  const beforeIndentWidth = this.rowModel.indentWidth;
567
895
  this.themeManager.setTheme(theme);
568
896
  const nextTheme = this.themeManager.get();
569
- this.rowModel.rowHeight = this.viewport.rowHeight = this.layoutOverrides.rowHeight ?? nextTheme.rowHeight;
570
- this.rowModel.indentWidth = this.viewport.indentWidth = this.layoutOverrides.indentWidth ?? nextTheme.indentWidth;
571
- if (beforeRowHeight !== this.rowModel.rowHeight || beforeIndentWidth !== this.rowModel.indentWidth) {
897
+ this.rowModel.rowHeight = this.viewport.rowHeight =
898
+ this.layoutOverrides.rowHeight ?? nextTheme.rowHeight;
899
+ this.rowModel.indentWidth = this.viewport.indentWidth =
900
+ this.layoutOverrides.indentWidth ?? nextTheme.indentWidth;
901
+ if (
902
+ beforeRowHeight !== this.rowModel.rowHeight ||
903
+ beforeIndentWidth !== this.rowModel.indentWidth
904
+ ) {
572
905
  this.closeEditor();
573
906
  this.#rebuildRows();
574
907
  }
575
908
  this.#nativeScroll?.applyTheme?.(nextTheme);
576
- this.events.emit('themechange', { theme: nextTheme });
909
+ this.events.emit('themechange', {
910
+ theme: nextTheme
911
+ });
577
912
  }
578
913
 
579
914
  setLayoutMetrics(options = {}) {
580
- const rowHeight = Object.hasOwn(options, 'rowHeight') ? options.rowHeight ?? this.themeManager.get().rowHeight : this.rowModel.rowHeight;
581
- const indentWidth = Object.hasOwn(options, 'indentWidth') ? options.indentWidth ?? this.themeManager.get().indentWidth : this.rowModel.indentWidth;
915
+ const rowHeight = Object.hasOwn(options, 'rowHeight')
916
+ ? (options.rowHeight ?? this.themeManager.get().rowHeight)
917
+ : this.rowModel.rowHeight;
918
+ const indentWidth = Object.hasOwn(options, 'indentWidth')
919
+ ? (options.indentWidth ?? this.themeManager.get().indentWidth)
920
+ : this.rowModel.indentWidth;
582
921
  const headerHeight = options.headerHeight ?? this.viewport.headerHeight;
583
922
  assertPositiveNumber(rowHeight, 'rowHeight');
584
923
  assertPositiveNumber(indentWidth, 'indentWidth');
@@ -586,7 +925,8 @@ export class TreeViewController {
586
925
  for (const key of ['rowHeight', 'indentWidth']) {
587
926
  if (Object.hasOwn(options, key)) this.layoutOverrides[key] = options[key];
588
927
  }
589
- const rowsChanged = this.rowModel.rowHeight !== rowHeight || this.rowModel.indentWidth !== indentWidth;
928
+ const rowsChanged =
929
+ this.rowModel.rowHeight !== rowHeight || this.rowModel.indentWidth !== indentWidth;
590
930
  if (rowsChanged || headerHeight !== this.viewport.headerHeight) this.closeEditor();
591
931
  this.rowModel.rowHeight = rowHeight;
592
932
  this.rowModel.indentWidth = indentWidth;
@@ -605,12 +945,17 @@ export class TreeViewController {
605
945
  expand(nodeId) {
606
946
  if (this.filterQuery && this.rowModel.expandFilterBranch(nodeId)) {
607
947
  this.#rebuildRows();
608
- this.events.emit('expand', { nodeId, filter: true });
948
+ this.events.emit('expand', {
949
+ nodeId,
950
+ filter: true
951
+ });
609
952
  return true;
610
953
  }
611
954
  if (!this.expansion.expand(nodeId)) return false;
612
955
  this.#rebuildRows();
613
- this.events.emit('expand', { nodeId });
956
+ this.events.emit('expand', {
957
+ nodeId
958
+ });
614
959
  return true;
615
960
  }
616
961
 
@@ -621,51 +966,76 @@ export class TreeViewController {
621
966
  if (shouldCollapseFilterBranch) this.rowModel.collapseFilterBranch(nodeId);
622
967
  if (!changed && !shouldCollapseFilterBranch) return false;
623
968
  this.#rebuildRows();
624
- this.events.emit('collapse', { nodeId, filter: shouldCollapseFilterBranch });
969
+ this.events.emit('collapse', {
970
+ nodeId,
971
+ filter: shouldCollapseFilterBranch
972
+ });
625
973
  return true;
626
974
  }
627
975
 
628
976
  toggle(nodeId) {
629
977
  const row = this.rowModel.getRowById(nodeId);
630
- return (row?.expanded ?? this.expansion.isExpanded(nodeId)) ? this.collapse(nodeId) : this.expand(nodeId);
978
+ return (row?.expanded ?? this.expansion.isExpanded(nodeId))
979
+ ? this.collapse(nodeId)
980
+ : this.expand(nodeId);
631
981
  }
632
982
 
633
983
  expandAll() {
634
984
  this.expansion.expandAll();
635
985
  this.#rebuildRows();
636
- this.events.emit('expand', { nodeId: null, all: true });
986
+ this.events.emit('expand', {
987
+ nodeId: null,
988
+ all: true
989
+ });
637
990
  }
638
991
 
639
992
  collapseAll() {
640
993
  this.expansion.collapseAll();
641
994
  this.#rebuildRows();
642
- this.events.emit('collapse', { nodeId: null, all: true });
995
+ this.events.emit('collapse', {
996
+ nodeId: null,
997
+ all: true
998
+ });
643
999
  }
644
1000
 
645
1001
  search(query, options = {}) {
646
- for (const id of this.searchHighlights) this.patchBatcher.set(id, { highlighted: false });
1002
+ for (const id of this.searchHighlights)
1003
+ this.patchBatcher.set(id, {
1004
+ highlighted: false
1005
+ });
647
1006
  const results = this.searchIndex.search(query, {
648
1007
  limit: options.limit ?? 500,
649
1008
  fields: options.fields,
650
1009
  caseSensitive: options.caseSensitive,
651
- wholeWord: options.wholeWord,
1010
+ wholeWord: options.wholeWord
652
1011
  });
653
1012
  this.searchHighlights = new Set(results);
654
1013
  const expandedSizeBefore = this.expansion.model.expanded.size;
655
1014
  for (const id of results) {
656
1015
  if (options.expand !== false) this.expansion.expandAncestors(id);
657
- this.patchBatcher.set(id, { highlighted: true });
1016
+ this.patchBatcher.set(id, {
1017
+ highlighted: true
1018
+ });
658
1019
  }
659
- if (options.expand !== false && this.expansion.model.expanded.size !== expandedSizeBefore) this.#rebuildRows();
1020
+ if (options.expand !== false && this.expansion.model.expanded.size !== expandedSizeBefore)
1021
+ this.#rebuildRows();
660
1022
  this.setDynamicState(this.patchBatcher.flush());
1023
+ this.flushDynamicState();
661
1024
  if (results[0] && options.focus !== false) {
662
1025
  this.scrollToNode(results[0], options.align ?? 'nearest');
663
1026
  this.focusedId = results[0];
664
1027
  this.selection.focused = results[0];
665
1028
  if (options.select) this.setSelection([results[0]]);
666
- this.events.emit('focuschange', { nodeId: results[0] });
1029
+ this.events.emit('focuschange', {
1030
+ nodeId: results[0]
1031
+ });
667
1032
  }
668
- this.events.emit('searchchange', { query, results, cursor: this.searchIndex.cursor, current: this.searchIndex.currentSearchResult() });
1033
+ this.events.emit('searchchange', {
1034
+ query,
1035
+ results,
1036
+ cursor: this.searchIndex.cursor,
1037
+ current: this.searchIndex.currentSearchResult()
1038
+ });
669
1039
  return results;
670
1040
  }
671
1041
 
@@ -673,13 +1043,16 @@ export class TreeViewController {
673
1043
  if (!this.workerClient) return this.search(query, options);
674
1044
  const totalStart = now();
675
1045
  const revision = ++this.workerRevision;
676
- for (const id of this.searchHighlights) this.patchBatcher.set(id, { highlighted: false });
1046
+ for (const id of this.searchHighlights)
1047
+ this.patchBatcher.set(id, {
1048
+ highlighted: false
1049
+ });
677
1050
  const searchStart = now();
678
1051
  const results = await this.workerClient.search(query, {
679
1052
  limit: options.limit ?? 500,
680
1053
  fields: options.fields,
681
1054
  caseSensitive: options.caseSensitive,
682
- wholeWord: options.wholeWord,
1055
+ wholeWord: options.wholeWord
683
1056
  });
684
1057
  const searchMs = now() - searchStart;
685
1058
  if (revision !== this.workerRevision) return this.searchIndex.results;
@@ -690,24 +1063,34 @@ export class TreeViewController {
690
1063
  const expandedSizeBefore = this.expansion.model.expanded.size;
691
1064
  for (const id of results) {
692
1065
  if (options.expand !== false) this.expansion.expandAncestors(id);
693
- this.patchBatcher.set(id, { highlighted: true });
1066
+ this.patchBatcher.set(id, {
1067
+ highlighted: true
1068
+ });
694
1069
  }
695
1070
  let rowMs = 0;
696
- const expansionChanged = options.expand !== false && this.expansion.model.expanded.size !== expandedSizeBefore;
1071
+ const expansionChanged =
1072
+ options.expand !== false && this.expansion.model.expanded.size !== expandedSizeBefore;
697
1073
  if (expansionChanged || this.filterQuery) {
698
1074
  const rowStart = now();
699
- const rowResult = await this.workerClient.rebuildRows(this.#workerRowOptions({ filterQuery: this.filterQuery }));
1075
+ const rowResult = await this.workerClient.rebuildRows(
1076
+ this.#workerRowOptions({
1077
+ filterQuery: this.filterQuery
1078
+ })
1079
+ );
700
1080
  rowMs = now() - rowStart;
701
1081
  if (revision !== this.workerRevision) return this.searchIndex.results;
702
1082
  this.#applyWorkerRows(rowResult);
703
1083
  }
704
1084
  this.setDynamicState(this.patchBatcher.flush());
1085
+ this.flushDynamicState();
705
1086
  if (results[0] && options.focus !== false) {
706
1087
  this.scrollToNode(results[0], options.align ?? 'nearest');
707
1088
  this.focusedId = results[0];
708
1089
  this.selection.focused = results[0];
709
1090
  if (options.select) this.setSelection([results[0]]);
710
- this.events.emit('focuschange', { nodeId: results[0] });
1091
+ this.events.emit('focuschange', {
1092
+ nodeId: results[0]
1093
+ });
711
1094
  }
712
1095
  this.events.emit('searchchange', {
713
1096
  query,
@@ -718,18 +1101,27 @@ export class TreeViewController {
718
1101
  searchMs,
719
1102
  rowMs,
720
1103
  workerMs: searchMs + rowMs,
721
- totalMs: now() - totalStart,
1104
+ totalMs: now() - totalStart
722
1105
  });
723
1106
  return results;
724
1107
  }
725
1108
 
726
1109
  clearSearch() {
727
1110
  this.workerRevision++;
728
- for (const id of this.searchHighlights) this.patchBatcher.set(id, { highlighted: false });
1111
+ for (const id of this.searchHighlights)
1112
+ this.patchBatcher.set(id, {
1113
+ highlighted: false
1114
+ });
729
1115
  this.searchHighlights.clear();
730
1116
  this.searchIndex.clear();
731
1117
  this.setDynamicState(this.patchBatcher.flush());
732
- this.events.emit('searchchange', { query: '', results: [], cursor: -1, current: null });
1118
+ this.flushDynamicState();
1119
+ this.events.emit('searchchange', {
1120
+ query: '',
1121
+ results: [],
1122
+ cursor: -1,
1123
+ current: null
1124
+ });
733
1125
  }
734
1126
 
735
1127
  getSearchState() {
@@ -738,21 +1130,37 @@ export class TreeViewController {
738
1130
  results: this.searchIndex.results.slice(),
739
1131
  cursor: this.searchIndex.cursor,
740
1132
  current: this.searchIndex.currentSearchResult(),
741
- count: this.searchIndex.results.length,
1133
+ count: this.searchIndex.results.length
742
1134
  };
743
1135
  }
744
1136
 
745
1137
  nextSearchResult() {
746
1138
  const id = this.searchIndex.nextSearchResult();
747
- if (id) this.focusNode(id, { select: false });
748
- this.events.emit('searchchange', { query: this.searchIndex.lastQuery, results: this.searchIndex.results.slice(), cursor: this.searchIndex.cursor, current: id });
1139
+ if (id)
1140
+ this.focusNode(id, {
1141
+ select: false
1142
+ });
1143
+ this.events.emit('searchchange', {
1144
+ query: this.searchIndex.lastQuery,
1145
+ results: this.searchIndex.results.slice(),
1146
+ cursor: this.searchIndex.cursor,
1147
+ current: id
1148
+ });
749
1149
  return id;
750
1150
  }
751
1151
 
752
1152
  previousSearchResult() {
753
1153
  const id = this.searchIndex.previousSearchResult();
754
- if (id) this.focusNode(id, { select: false });
755
- this.events.emit('searchchange', { query: this.searchIndex.lastQuery, results: this.searchIndex.results.slice(), cursor: this.searchIndex.cursor, current: id });
1154
+ if (id)
1155
+ this.focusNode(id, {
1156
+ select: false
1157
+ });
1158
+ this.events.emit('searchchange', {
1159
+ query: this.searchIndex.lastQuery,
1160
+ results: this.searchIndex.results.slice(),
1161
+ cursor: this.searchIndex.cursor,
1162
+ current: id
1163
+ });
756
1164
  return id;
757
1165
  }
758
1166
 
@@ -764,7 +1172,9 @@ export class TreeViewController {
764
1172
  this.focusedId = nodeId;
765
1173
  this.selection.focused = nodeId;
766
1174
  if (options.select) this.setSelection([nodeId]);
767
- this.events.emit('focuschange', { nodeId });
1175
+ this.events.emit('focuschange', {
1176
+ nodeId
1177
+ });
768
1178
  return true;
769
1179
  }
770
1180
 
@@ -786,9 +1196,14 @@ export class TreeViewController {
786
1196
  for (const id of ids) this.selection.selected.add(id);
787
1197
  this.selection.focused = ids[ids.length - 1] ?? null;
788
1198
  this.focusedId = this.selection.focused;
789
- this.anchorRowIndex = this.focusedId ? this.rowModel.getRowById(this.focusedId)?.rowIndex ?? null : null;
1199
+ this.anchorRowIndex = this.focusedId
1200
+ ? (this.rowModel.getRowById(this.focusedId)?.rowIndex ?? null)
1201
+ : null;
790
1202
  this.#syncSelectionState();
791
- this.events.emit('selectionchange', { selection: this.getSelection(), focusedId: this.focusedId });
1203
+ this.events.emit('selectionchange', {
1204
+ selection: this.getSelection(),
1205
+ focusedId: this.focusedId
1206
+ });
792
1207
  }
793
1208
 
794
1209
  clearSelection() {
@@ -799,7 +1214,9 @@ export class TreeViewController {
799
1214
  if (this.hoverId === nodeId) return;
800
1215
  this.hoverId = nodeId;
801
1216
  this.hoverPart = null;
802
- this.events.emit('nodehover', { nodeId });
1217
+ this.events.emit('nodehover', {
1218
+ nodeId
1219
+ });
803
1220
  }
804
1221
 
805
1222
  setHoverHit(hit) {
@@ -808,7 +1225,10 @@ export class TreeViewController {
808
1225
  if (this.hoverId === nodeId && this.hoverPart === part) return;
809
1226
  this.hoverId = nodeId;
810
1227
  this.hoverPart = part;
811
- this.events.emit('nodehover', { nodeId, part });
1228
+ this.events.emit('nodehover', {
1229
+ nodeId,
1230
+ part
1231
+ });
812
1232
  }
813
1233
 
814
1234
  setActiveHit(hit) {
@@ -824,14 +1244,22 @@ export class TreeViewController {
824
1244
  const row = this.rowModel.getRowById(nodeId);
825
1245
  if (!row) return;
826
1246
  this.selectRow(row.rowIndex, event);
827
- this.events.emit('nodeclick', { nodeId, row, originalEvent: event });
1247
+ this.events.emit('nodeclick', {
1248
+ nodeId,
1249
+ row,
1250
+ originalEvent: event
1251
+ });
828
1252
  }
829
1253
 
830
1254
  doubleClickNode(nodeId, event = {}) {
831
1255
  const row = this.rowModel.getRowById(nodeId);
832
1256
  if (!row) return;
833
1257
  if (row.hasChildren) this.toggle(nodeId);
834
- this.events.emit('nodedblclick', { nodeId, row, originalEvent: event });
1258
+ this.events.emit('nodedblclick', {
1259
+ nodeId,
1260
+ row,
1261
+ originalEvent: event
1262
+ });
835
1263
  }
836
1264
 
837
1265
  selectRow(rowIndex, event = {}) {
@@ -854,8 +1282,13 @@ export class TreeViewController {
854
1282
  this.focusedId = row.nodeId;
855
1283
  this.selection.focused = row.nodeId;
856
1284
  this.#syncSelectionState();
857
- this.events.emit('selectionchange', { selection: this.getSelection(), focusedId: this.focusedId });
858
- this.events.emit('focuschange', { nodeId: this.focusedId });
1285
+ this.events.emit('selectionchange', {
1286
+ selection: this.getSelection(),
1287
+ focusedId: this.focusedId
1288
+ });
1289
+ this.events.emit('focuschange', {
1290
+ nodeId: this.focusedId
1291
+ });
859
1292
  return true;
860
1293
  }
861
1294
 
@@ -863,7 +1296,9 @@ export class TreeViewController {
863
1296
  if (!this.rowModel.rows.length) return false;
864
1297
  if (this.rowReorder && event.altKey && ['ArrowUp', 'ArrowDown'].includes(event.key)) {
865
1298
  const id = this.focusedId ?? this.rowModel.getRow(this.#focusedRowIndex())?.nodeId;
866
- this.moveRowBy(id, event.key === 'ArrowUp' ? -1 : 1, { source: 'keyboard' });
1299
+ this.moveRowBy(id, event.key === 'ArrowUp' ? -1 : 1, {
1300
+ source: 'keyboard'
1301
+ });
867
1302
  return true;
868
1303
  }
869
1304
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
@@ -873,15 +1308,28 @@ export class TreeViewController {
873
1308
  this.focusedId = focusedRow?.nodeId ?? this.focusedId;
874
1309
  this.selection.focused = this.focusedId;
875
1310
  this.#syncSelectionState();
876
- this.events.emit('selectionchange', { selection: this.getSelection(), focusedId: this.focusedId });
1311
+ this.events.emit('selectionchange', {
1312
+ selection: this.getSelection(),
1313
+ focusedId: this.focusedId
1314
+ });
877
1315
  return true;
878
1316
  }
879
1317
  const currentRow = this.#focusedRowIndex();
880
1318
  let target = currentRow;
881
1319
  if (event.key === 'ArrowDown') target = Math.min(this.rowModel.rows.length - 1, currentRow + 1);
882
1320
  else if (event.key === 'ArrowUp') target = Math.max(0, currentRow - 1);
883
- else if (event.key === 'PageDown') target = Math.min(this.rowModel.rows.length - 1, currentRow + Math.max(1, Math.floor(this.viewport.rowViewportHeight / this.rowModel.rowHeight) - 1));
884
- else if (event.key === 'PageUp') target = Math.max(0, currentRow - Math.max(1, Math.floor(this.viewport.rowViewportHeight / this.rowModel.rowHeight) - 1));
1321
+ else if (event.key === 'PageDown')
1322
+ target = Math.min(
1323
+ this.rowModel.rows.length - 1,
1324
+ currentRow +
1325
+ Math.max(1, Math.floor(this.viewport.rowViewportHeight / this.rowModel.rowHeight) - 1)
1326
+ );
1327
+ else if (event.key === 'PageUp')
1328
+ target = Math.max(
1329
+ 0,
1330
+ currentRow -
1331
+ Math.max(1, Math.floor(this.viewport.rowViewportHeight / this.rowModel.rowHeight) - 1)
1332
+ );
885
1333
  else if (event.key === 'Home') target = 0;
886
1334
  else if (event.key === 'End') target = this.rowModel.rows.length - 1;
887
1335
  else if (event.key === 'ArrowRight') {
@@ -896,13 +1344,20 @@ export class TreeViewController {
896
1344
  if (parentRow) target = parentRow.rowIndex;
897
1345
  } else if (event.key === 'Enter' || event.key === ' ') {
898
1346
  const row = this.rowModel.getRow(currentRow);
899
- if (row) this.selectRow(row.rowIndex, { ctrlKey: true });
1347
+ if (row)
1348
+ this.selectRow(row.rowIndex, {
1349
+ ctrlKey: true
1350
+ });
900
1351
  return true;
901
1352
  } else return false;
902
1353
 
903
1354
  const row = this.rowModel.getRow(target);
904
1355
  if (!row) return false;
905
- this.selectRow(target, { shiftKey: event.shiftKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey });
1356
+ this.selectRow(target, {
1357
+ shiftKey: event.shiftKey,
1358
+ ctrlKey: event.ctrlKey,
1359
+ metaKey: event.metaKey
1360
+ });
906
1361
  this.viewport.scrollRowIntoView(target, 'nearest');
907
1362
  this.events.emit('viewportchange', this.getViewportState());
908
1363
  return true;
@@ -919,23 +1374,37 @@ export class TreeViewController {
919
1374
  }
920
1375
 
921
1376
  render(time = performance.now()) {
1377
+ this.cancelRender();
922
1378
  this.renderMeasured(time);
923
1379
  }
924
1380
 
925
1381
  renderMeasured(time = performance.now()) {
1382
+ if (this.renderFrame !== null) this.cancelRender();
1383
+ this.flushDynamicState();
926
1384
  const sceneStart = performance.now();
927
1385
  this.scene = this.createRenderScene();
928
1386
  const sceneMs = performance.now() - sceneStart;
929
1387
  this.renderer.setScene(this.scene);
930
1388
  const renderStart = performance.now();
931
1389
  this.renderer.render(this.scene, time);
932
- this.actionOverlay?.render(this.scene);
1390
+ this.actionOverlay?.render(this.scene, this.invalidation);
1391
+ this.tooltip?.refresh();
933
1392
  const renderMs = performance.now() - renderStart;
934
1393
  this.lastRenderedRows = this.renderer.renderedRows ?? 0;
935
- return { scene: this.scene, sceneMs, renderMs, renderedRows: this.lastRenderedRows };
1394
+ this.performanceStats.rendersExecuted++;
1395
+ this.lastPatchCount = 0;
1396
+ this.invalidation.full = false;
1397
+ this.invalidation.dirtyNodeIds.clear();
1398
+ return {
1399
+ scene: this.scene,
1400
+ sceneMs,
1401
+ renderMs,
1402
+ renderedRows: this.lastRenderedRows
1403
+ };
936
1404
  }
937
1405
 
938
1406
  createRenderScene() {
1407
+ this.flushDynamicState();
939
1408
  const visibleRange = this.rowModel.getVisibleRange(this.viewport, 4);
940
1409
  return {
941
1410
  rowDrag: this.rowDrag,
@@ -959,11 +1428,10 @@ export class TreeViewController {
959
1428
  focusNodeId: this.focusedId,
960
1429
  searchMatches: this.searchHighlights,
961
1430
  sort: this.columnModel.sort,
962
- sortValues: this.sortValueSnapshot ? Array.from(this.sortValueSnapshot) : null,
963
1431
  inspectorPaneLabelEnd: this.#computeInspectorPaneLabelEnd(visibleRange),
964
1432
  filterQuery: this.filterQuery,
965
1433
  headerFilter: this.headerFilter ?? Boolean(this.inspector?.options?.filter),
966
- stats: this.getStats(),
1434
+ stats: this.getStats()
967
1435
  };
968
1436
  }
969
1437
 
@@ -980,12 +1448,46 @@ export class TreeViewController {
980
1448
  const x = localX + this.viewport.scrollX;
981
1449
  if (localY < this.viewport.headerHeight) {
982
1450
  const resizeColumn = this.columnModel.getResizeHandleAt(x);
983
- if (resizeColumn) return { area: 'header', part: 'resize', column: resizeColumn, x, y: localY };
1451
+ if (resizeColumn)
1452
+ return {
1453
+ area: 'header',
1454
+ part: 'resize',
1455
+ column: resizeColumn,
1456
+ x,
1457
+ y: localY
1458
+ };
984
1459
  const column = this.columnModel.getColumnAt(x);
985
- if (column && (this.headerFilter ?? Boolean(this.inspector?.options?.filter)) && column === this.columnModel.columns.find(item => item.kind !== 'rowOrder' && item.id !== '__vtc_actions')) {
986
- return { area: 'header', part: 'filter', column, x, y: localY };
1460
+ if (
1461
+ column &&
1462
+ (this.headerFilter ?? Boolean(this.inspector?.options?.filter)) &&
1463
+ column ===
1464
+ this.columnModel.columns.find(
1465
+ (item) => item.kind !== 'rowOrder' && item.id !== '__vtc_actions'
1466
+ )
1467
+ ) {
1468
+ return {
1469
+ area: 'header',
1470
+ part: 'filter',
1471
+ column,
1472
+ x,
1473
+ y: localY
1474
+ };
987
1475
  }
988
- return column ? { area: 'header', part: 'label', column, x, y: localY } : { area: 'header', part: 'header', column: null, x, y: localY };
1476
+ return column
1477
+ ? {
1478
+ area: 'header',
1479
+ part: 'label',
1480
+ column,
1481
+ x,
1482
+ y: localY
1483
+ }
1484
+ : {
1485
+ area: 'header',
1486
+ part: 'header',
1487
+ column: null,
1488
+ x,
1489
+ y: localY
1490
+ };
989
1491
  }
990
1492
 
991
1493
  if (localY >= this.viewport.headerHeight + this.viewport.rowViewportHeight) return null;
@@ -994,11 +1496,26 @@ export class TreeViewController {
994
1496
  const row = this.rowModel.getRow(rowIndex);
995
1497
  if (!row) return null;
996
1498
  const column = this.columnModel.getColumnAt(x);
997
- if (!column) return { area: 'row', part: 'row', row, column: null, x, y: rowY };
1499
+ if (!column)
1500
+ return {
1501
+ area: 'row',
1502
+ part: 'row',
1503
+ row,
1504
+ column: null,
1505
+ x,
1506
+ y: rowY
1507
+ };
998
1508
 
999
1509
  if (column.kind === 'rowOrder') {
1000
1510
  const offset = x - column.x;
1001
- return { area: 'row', part: offset < 24 ? 'rowDrag' : offset < 48 ? 'rowUp' : 'rowDown', row, column, x, y: rowY };
1511
+ return {
1512
+ area: 'row',
1513
+ part: offset < 24 ? 'rowDrag' : offset < 48 ? 'rowUp' : 'rowDown',
1514
+ row,
1515
+ column,
1516
+ x,
1517
+ y: rowY
1518
+ };
1002
1519
  }
1003
1520
  let part = 'cell';
1004
1521
  if (column.kind === 'inspectorPane') {
@@ -1007,23 +1524,46 @@ export class TreeViewController {
1007
1524
  if (node?.data?.valueType === 'object') {
1008
1525
  const treeX = row.depth * this.rowModel.indentWidth;
1009
1526
  part = localX >= treeX + 4 && localX <= treeX + 22 ? 'chevron' : 'label';
1010
- return { area: 'row', part, row, column, x, y: rowY };
1527
+ return {
1528
+ area: 'row',
1529
+ part,
1530
+ row,
1531
+ column,
1532
+ x,
1533
+ y: rowY
1534
+ };
1011
1535
  }
1012
1536
  if (node?.data?.valueType === 'array') {
1013
1537
  if (localX >= column.width - 54 && localX <= column.width - 32) part = 'arrayAdd';
1014
1538
  else if (localX >= column.width - 28 && localX <= column.width - 6) part = 'arrayRemove';
1015
1539
  else {
1016
- const editorLeft = this.getInspectorPaneLayout(this.#visibleInspectorPaneWidth(column), row, node?.data?.editorType).editorLeft;
1540
+ const editorLeft = this.getInspectorPaneLayout(
1541
+ this.#visibleInspectorPaneWidth(column),
1542
+ row,
1543
+ node?.data?.editorType
1544
+ ).editorLeft;
1017
1545
  if (localX >= editorLeft) part = 'editor';
1018
1546
  else {
1019
1547
  const treeX = row.depth * this.rowModel.indentWidth;
1020
1548
  part = localX >= treeX + 4 && localX <= treeX + 22 ? 'chevron' : 'label';
1021
1549
  }
1022
1550
  }
1023
- return { area: 'row', part, row, column, x, y: rowY };
1551
+ return {
1552
+ area: 'row',
1553
+ part,
1554
+ row,
1555
+ column,
1556
+ x,
1557
+ y: rowY
1558
+ };
1024
1559
  }
1025
- const layout = this.getInspectorPaneLayout(this.#visibleInspectorPaneWidth(column), row, node?.data?.editorType);
1026
- if (localX >= layout.editorLeft) part = this.#inspectorEditorPart(row, localX - layout.editorLeft, layout.editorWidth);
1560
+ const layout = this.getInspectorPaneLayout(
1561
+ this.#visibleInspectorPaneWidth(column),
1562
+ row,
1563
+ node?.data?.editorType
1564
+ );
1565
+ if (localX >= layout.editorLeft)
1566
+ part = this.#inspectorEditorPart(row, localX - layout.editorLeft, layout.editorWidth);
1027
1567
  else {
1028
1568
  const treeX = row.depth * this.rowModel.indentWidth;
1029
1569
  if (localX >= treeX + 4 && localX <= treeX + 22) part = 'chevron';
@@ -1031,15 +1571,27 @@ export class TreeViewController {
1031
1571
  }
1032
1572
  } else if (column.kind === 'tree') {
1033
1573
  const localX = x - column.x;
1034
- const layout = treeCellLayout(row.depth, this.rowModel.indentWidth, this.model.index.childrenByParent.size > 1);
1035
- if (row.hasChildren && localX >= layout.chevronX - 6 && localX <= layout.chevronX + 12) part = 'chevron';
1574
+ const layout = treeCellLayout(
1575
+ row.depth,
1576
+ this.rowModel.indentWidth,
1577
+ this.model.index.childrenByParent.size > 1
1578
+ );
1579
+ if (row.hasChildren && localX >= layout.chevronX - 6 && localX <= layout.chevronX + 12)
1580
+ part = 'chevron';
1036
1581
  else if (localX >= layout.iconX - 1 && localX <= layout.iconX + 17) part = 'icon';
1037
1582
  else if (localX >= layout.labelX - 2) part = 'label';
1038
1583
  else part = 'cell';
1039
1584
  } else if (column.kind === 'inspectorValue') {
1040
1585
  part = this.#inspectorEditorPart(row, x - column.x, column.width);
1041
1586
  }
1042
- return { area: 'row', part, row, column, x, y: rowY };
1587
+ return {
1588
+ area: 'row',
1589
+ part,
1590
+ row,
1591
+ column,
1592
+ x,
1593
+ y: rowY
1594
+ };
1043
1595
  }
1044
1596
 
1045
1597
  #inspectorEditorPart(row, localEditorX, editorWidth) {
@@ -1058,33 +1610,62 @@ export class TreeViewController {
1058
1610
  }
1059
1611
 
1060
1612
  #visibleInspectorPaneWidth(column) {
1061
- return Math.max(1, Math.min(column.width, this.viewport.scrollX + this.viewport.contentViewportWidth - column.x));
1613
+ return Math.max(
1614
+ 1,
1615
+ Math.min(column.width, this.viewport.scrollX + this.viewport.contentViewportWidth - column.x)
1616
+ );
1062
1617
  }
1063
1618
 
1064
1619
  getInspectorPaneLayout(width, row = null, editorType = '') {
1065
- return inspectorPaneLayout(width, row?.depth ?? 0, this.rowModel.indentWidth, editorType, this.#computeInspectorPaneLabelEnd(this.rowModel.getVisibleRange(this.viewport, 4)));
1620
+ return inspectorPaneLayout(
1621
+ width,
1622
+ row?.depth ?? 0,
1623
+ this.rowModel.indentWidth,
1624
+ editorType,
1625
+ this.#computeInspectorPaneLabelEnd(this.rowModel.getVisibleRange(this.viewport, 4))
1626
+ );
1066
1627
  }
1067
1628
 
1068
1629
  getCellClientRect(hit) {
1069
- if (!hit?.row || !hit.column || !this.canvas) return { x: 0, y: 0, width: 0, height: 0 };
1630
+ if (!hit?.row || !hit.column || !this.canvas)
1631
+ return {
1632
+ x: 0,
1633
+ y: 0,
1634
+ width: 0,
1635
+ height: 0
1636
+ };
1070
1637
  const rect = this.canvas.getBoundingClientRect();
1071
1638
  return {
1072
1639
  x: rect.left + (this.viewport.renderInsetX ?? 0) + hit.column.x - this.viewport.scrollX,
1073
- y: rect.top + (this.viewport.renderInsetY ?? 0) + this.viewport.headerHeight + hit.row.y - this.viewport.scrollY,
1640
+ y:
1641
+ rect.top +
1642
+ (this.viewport.renderInsetY ?? 0) +
1643
+ this.viewport.headerHeight +
1644
+ hit.row.y -
1645
+ this.viewport.scrollY,
1074
1646
  width: hit.column.width,
1075
- height: hit.row.height,
1647
+ height: hit.row.height
1076
1648
  };
1077
1649
  }
1078
1650
 
1079
1651
  getHeaderClientRect(hit) {
1080
- if (!hit?.column || !this.canvas) return { x: 0, y: 0, width: 0, height: 0 };
1652
+ if (!hit?.column || !this.canvas)
1653
+ return {
1654
+ x: 0,
1655
+ y: 0,
1656
+ width: 0,
1657
+ height: 0
1658
+ };
1081
1659
  const rect = this.canvas.getBoundingClientRect();
1082
- const visibleWidth = Math.max(0, this.viewport.contentViewportWidth - Math.max(0, hit.column.x - this.viewport.scrollX));
1660
+ const visibleWidth = Math.max(
1661
+ 0,
1662
+ this.viewport.contentViewportWidth - Math.max(0, hit.column.x - this.viewport.scrollX)
1663
+ );
1083
1664
  return {
1084
1665
  x: rect.left + (this.viewport.renderInsetX ?? 0) + hit.column.x - this.viewport.scrollX,
1085
1666
  y: rect.top + (this.viewport.renderInsetY ?? 0),
1086
1667
  width: Math.min(hit.column.width, visibleWidth || hit.column.width),
1087
- height: this.viewport.headerHeight,
1668
+ height: this.viewport.headerHeight
1088
1669
  };
1089
1670
  }
1090
1671
 
@@ -1104,7 +1685,10 @@ export class TreeViewController {
1104
1685
  if (hit.part === 'label' || hit.part === 'chevron') {
1105
1686
  const labelX = hit.row.depth * this.rowModel.indentWidth + (hit.row.hasChildren ? 28 : 24);
1106
1687
  text = node.label ?? node.id;
1107
- width = data.valueType === 'object' ? Math.max(0, visibleWidth - labelX - 8) : Math.max(0, layout.editorLeft - labelX - 8);
1688
+ width =
1689
+ data.valueType === 'object'
1690
+ ? Math.max(0, visibleWidth - labelX - 8)
1691
+ : Math.max(0, layout.editorLeft - labelX - 8);
1108
1692
  } else if (hit.part === 'arrayAdd' || hit.part === 'arrayRemove') {
1109
1693
  return null;
1110
1694
  } else {
@@ -1112,7 +1696,11 @@ export class TreeViewController {
1112
1696
  width = Math.max(0, layout.editorWidth - 20);
1113
1697
  }
1114
1698
  } else if (hit.column.kind === 'tree') {
1115
- const { labelX } = treeCellLayout(hit.row.depth, this.rowModel.indentWidth, this.model.index.childrenByParent.size > 1);
1699
+ const { labelX } = treeCellLayout(
1700
+ hit.row.depth,
1701
+ this.rowModel.indentWidth,
1702
+ this.model.index.childrenByParent.size > 1
1703
+ );
1116
1704
  text = node.label ?? node.id;
1117
1705
  width = Math.max(0, hit.column.width - labelX - 6);
1118
1706
  } else if (hit.column.kind === 'inspectorValue') {
@@ -1126,17 +1714,28 @@ export class TreeViewController {
1126
1714
  width = Math.max(0, hit.column.width - 20);
1127
1715
  } else if (typeof hit.column.value === 'function') {
1128
1716
  const value = hit.column.value(node, state);
1129
- text = hit.column.format ? hit.column.format(value, node, state) : value == null ? '' : String(value);
1717
+ text = hit.column.format
1718
+ ? hit.column.format(value, node, state)
1719
+ : value == null
1720
+ ? ''
1721
+ : String(value);
1130
1722
  width = Math.max(0, hit.column.width - 20);
1131
1723
  }
1132
1724
 
1133
1725
  text = String(text ?? '');
1134
1726
  if (!text || !isProbablyTruncated(text, width)) return null;
1135
- return { text, rect, nodeId: node.id, part: hit.part, columnId: hit.column.id };
1727
+ return {
1728
+ text,
1729
+ rect,
1730
+ nodeId: node.id,
1731
+ part: hit.part,
1732
+ columnId: hit.column.id
1733
+ };
1136
1734
  }
1137
1735
 
1138
1736
  resize(width, height) {
1139
- if (width !== this.viewport.viewportWidth || height !== this.viewport.viewportHeight) this.closeEditor();
1737
+ if (width !== this.viewport.viewportWidth || height !== this.viewport.viewportHeight)
1738
+ this.closeEditor();
1140
1739
  this.viewport.resize(width, height);
1141
1740
  this.#fitInspectorPaneColumn(width);
1142
1741
  this.#syncContentSize();
@@ -1144,6 +1743,7 @@ export class TreeViewController {
1144
1743
  }
1145
1744
 
1146
1745
  getStats() {
1746
+ this.flushDynamicState();
1147
1747
  return {
1148
1748
  totalNodes: this.model.nodes.length,
1149
1749
  visibleRows: this.rowModel.rows.length,
@@ -1152,6 +1752,9 @@ export class TreeViewController {
1152
1752
  dirtyNodes: this.lastDirtyNodeCount,
1153
1753
  selectedCount: this.selection.selected.size,
1154
1754
  rebuildCount: this.rebuildCount,
1755
+ ...this.performanceStats,
1756
+ rowActionsFullUpdates: this.actionOverlay?.fullUpdates ?? 0,
1757
+ rowActionsIncrementalUpdates: this.actionOverlay?.incrementalUpdates ?? 0
1155
1758
  };
1156
1759
  }
1157
1760
 
@@ -1168,10 +1771,28 @@ export class TreeViewController {
1168
1771
  viewportHeight: this.viewport.viewportHeight,
1169
1772
  contentWidth: this.viewport.contentWidth,
1170
1773
  contentHeight: this.viewport.contentHeight,
1171
- zoom: this.viewport.zoom,
1774
+ zoom: this.viewport.zoom
1172
1775
  };
1173
1776
  }
1174
1777
 
1778
+ #hasInvalidation() {
1779
+ return this.invalidation.full || this.invalidation.dirtyNodeIds.size > 0;
1780
+ }
1781
+
1782
+ /** @param {Set<string>} ids */
1783
+ #hasVisibleDirtyNode(ids) {
1784
+ const rowIndexById = this.rowModel.rowIndexById;
1785
+ if (!(rowIndexById instanceof Map) || !this.viewport) return true;
1786
+ const range = this.rowModel.getVisibleRange(this.viewport, 4);
1787
+ for (const id of ids) {
1788
+ const index = rowIndexById.get(id);
1789
+ if (index !== undefined && index >= range.first && index <= range.last) return true;
1790
+ }
1791
+ const stickyRows = this.rowReorder ? [] : this.rowModel.getStickyRows(this.viewport);
1792
+ for (const row of stickyRows) if (ids.has(row.nodeId)) return true;
1793
+ return false;
1794
+ }
1795
+
1175
1796
  #rebuildRows() {
1176
1797
  const scrollAnchor = this.#captureScrollAnchor();
1177
1798
  this.rowModel.rebuild();
@@ -1183,17 +1804,25 @@ export class TreeViewController {
1183
1804
 
1184
1805
  #syncContentSize() {
1185
1806
  this.#syncScrollbarState(this.columnModel.contentWidth, this.rowModel.contentHeight);
1186
- if (this.viewport.viewportWidth > 1) this.#fitInspectorPaneColumn(this.viewport.contentViewportWidth);
1807
+ if (this.viewport.viewportWidth > 1)
1808
+ this.#fitInspectorPaneColumn(this.viewport.contentViewportWidth);
1187
1809
  this.#syncScrollbarState(this.columnModel.contentWidth, this.rowModel.contentHeight);
1188
1810
  this.viewport.setContentSize(this.columnModel.contentWidth, this.rowModel.contentHeight);
1189
1811
  }
1190
1812
 
1191
1813
  #syncSelectionState() {
1192
1814
  for (const [id, state] of this.model.dynamicState) {
1193
- if (state.selected) this.patchBatcher.set(id, { selected: false });
1815
+ if (state.selected)
1816
+ this.patchBatcher.set(id, {
1817
+ selected: false
1818
+ });
1194
1819
  }
1195
- for (const id of this.selection.selected) this.patchBatcher.set(id, { selected: true });
1820
+ for (const id of this.selection.selected)
1821
+ this.patchBatcher.set(id, {
1822
+ selected: true
1823
+ });
1196
1824
  this.setDynamicState(this.patchBatcher.flush());
1825
+ this.flushDynamicState();
1197
1826
  }
1198
1827
 
1199
1828
  #focusedRowIndex() {
@@ -1213,7 +1842,10 @@ export class TreeViewController {
1213
1842
  const height = Math.floor(entry?.contentRect?.height ?? 0);
1214
1843
  if (width > 0 && height > 0) {
1215
1844
  this.resize(width, height);
1216
- if (this.autoRender) { this.cancelRender(); this.render(); }
1845
+ if (this.autoRender) {
1846
+ this.cancelRender();
1847
+ this.render();
1848
+ }
1217
1849
  }
1218
1850
  });
1219
1851
  this.#resizeObserver.observe(this.canvas);
@@ -1221,8 +1853,12 @@ export class TreeViewController {
1221
1853
 
1222
1854
  #resizeToCanvasClientSize() {
1223
1855
  if (!this.canvas) return;
1224
- const width = Math.floor(this.canvas.clientWidth || this.canvas.getBoundingClientRect().width || 0);
1225
- const height = Math.floor(this.canvas.clientHeight || this.canvas.getBoundingClientRect().height || 0);
1856
+ const width = Math.floor(
1857
+ this.canvas.clientWidth || this.canvas.getBoundingClientRect().width || 0
1858
+ );
1859
+ const height = Math.floor(
1860
+ this.canvas.clientHeight || this.canvas.getBoundingClientRect().height || 0
1861
+ );
1226
1862
  if (width > 0 && height > 0) this.resize(width, height);
1227
1863
  }
1228
1864
 
@@ -1264,7 +1900,7 @@ export class TreeViewController {
1264
1900
  height: '1px',
1265
1901
  overflowX: 'hidden',
1266
1902
  overflowY: 'auto',
1267
- zIndex: '4',
1903
+ zIndex: '4'
1268
1904
  });
1269
1905
  Object.assign(horizontal.style, {
1270
1906
  position: 'absolute',
@@ -1274,7 +1910,7 @@ export class TreeViewController {
1274
1910
  height: `${size}px`,
1275
1911
  overflowX: 'auto',
1276
1912
  overflowY: 'hidden',
1277
- zIndex: '4',
1913
+ zIndex: '4'
1278
1914
  });
1279
1915
  Object.assign(corner.style, {
1280
1916
  position: 'absolute',
@@ -1284,15 +1920,15 @@ export class TreeViewController {
1284
1920
  height: `${size}px`,
1285
1921
  zIndex: '4',
1286
1922
  background: 'transparent',
1287
- pointerEvents: 'none',
1923
+ pointerEvents: 'none'
1288
1924
  });
1289
1925
  Object.assign(verticalSpacer.style, {
1290
1926
  width: '1px',
1291
- minHeight: '1px',
1927
+ minHeight: '1px'
1292
1928
  });
1293
1929
  Object.assign(horizontalSpacer.style, {
1294
1930
  height: '1px',
1295
- minWidth: '1px',
1931
+ minWidth: '1px'
1296
1932
  });
1297
1933
 
1298
1934
  host.appendChild(vertical);
@@ -1340,8 +1976,12 @@ export class TreeViewController {
1340
1976
  };
1341
1977
  const onViewportChange = () => sync();
1342
1978
 
1343
- vertical.addEventListener('scroll', onVerticalScroll, { passive: true });
1344
- horizontal.addEventListener('scroll', onHorizontalScroll, { passive: true });
1979
+ vertical.addEventListener('scroll', onVerticalScroll, {
1980
+ passive: true
1981
+ });
1982
+ horizontal.addEventListener('scroll', onHorizontalScroll, {
1983
+ passive: true
1984
+ });
1345
1985
  this.viewport.addEventListener('change', onViewportChange);
1346
1986
  sync();
1347
1987
 
@@ -1356,16 +1996,24 @@ export class TreeViewController {
1356
1996
  horizontal.remove();
1357
1997
  corner.remove();
1358
1998
  this.#nativeScrollbarSize = 0;
1359
- this.viewport.setScrollbarState({ size: 0, vertical: false, horizontal: false });
1999
+ this.viewport.setScrollbarState({
2000
+ size: 0,
2001
+ vertical: false,
2002
+ horizontal: false
2003
+ });
1360
2004
  if (hostPositionChanged) host.style.position = previousHostPosition;
1361
- },
2005
+ }
1362
2006
  };
1363
2007
  }
1364
2008
 
1365
2009
  #syncScrollbarState(contentWidth, contentHeight) {
1366
2010
  const size = this.nativeScrollbars ? this.#nativeScrollbarSize : 0;
1367
2011
  if (!size) {
1368
- this.viewport.setScrollbarState({ size: 0, vertical: false, horizontal: false });
2012
+ this.viewport.setScrollbarState({
2013
+ size: 0,
2014
+ vertical: false,
2015
+ horizontal: false
2016
+ });
1369
2017
  return;
1370
2018
  }
1371
2019
  let availableWidth = this.viewport.viewportWidth;
@@ -1374,16 +2022,45 @@ export class TreeViewController {
1374
2022
  let showX = contentWidth > availableWidth;
1375
2023
  if (showY && contentWidth > Math.max(1, availableWidth - size)) showX = true;
1376
2024
  if (showX && contentHeight > Math.max(1, availableRowHeight - size)) showY = true;
1377
- this.viewport.setScrollbarState({ size, vertical: showY, horizontal: showX });
2025
+ this.viewport.setScrollbarState({
2026
+ size,
2027
+ vertical: showY,
2028
+ horizontal: showX
2029
+ });
1378
2030
  }
1379
2031
 
1380
2032
  #canvasHostMetrics() {
1381
- if (!this.canvas) return { left: 0, top: 0, width: this.viewport.viewportWidth, height: this.viewport.viewportHeight };
2033
+ if (!this.canvas)
2034
+ return {
2035
+ left: 0,
2036
+ top: 0,
2037
+ width: this.viewport.viewportWidth,
2038
+ height: this.viewport.viewportHeight
2039
+ };
1382
2040
  const left = Number.isFinite(this.canvas.offsetLeft) ? this.canvas.offsetLeft : 0;
1383
2041
  const top = Number.isFinite(this.canvas.offsetTop) ? this.canvas.offsetTop : 0;
1384
- const width = Math.max(1, Math.floor(this.canvas.clientWidth || this.canvas.getBoundingClientRect?.().width || this.viewport.viewportWidth));
1385
- const height = Math.max(1, Math.floor(this.canvas.clientHeight || this.canvas.getBoundingClientRect?.().height || this.viewport.viewportHeight));
1386
- return { left, top, width, height };
2042
+ const width = Math.max(
2043
+ 1,
2044
+ Math.floor(
2045
+ this.canvas.clientWidth ||
2046
+ this.canvas.getBoundingClientRect?.().width ||
2047
+ this.viewport.viewportWidth
2048
+ )
2049
+ );
2050
+ const height = Math.max(
2051
+ 1,
2052
+ Math.floor(
2053
+ this.canvas.clientHeight ||
2054
+ this.canvas.getBoundingClientRect?.().height ||
2055
+ this.viewport.viewportHeight
2056
+ )
2057
+ );
2058
+ return {
2059
+ left,
2060
+ top,
2061
+ width,
2062
+ height
2063
+ };
1387
2064
  }
1388
2065
 
1389
2066
  #computeInspectorPaneLabelEnd(visibleRange) {
@@ -1405,9 +2082,13 @@ export class TreeViewController {
1405
2082
  }
1406
2083
 
1407
2084
  #fitInspectorPaneColumn(width) {
1408
- const columns = this.columnModel.columns.filter(c => !['__vtc_actions', '__vtc_row_order'].includes(c.id));
2085
+ const columns = this.columnModel.columns.filter(
2086
+ (c) => !['__vtc_actions', '__vtc_row_order'].includes(c.id)
2087
+ );
1409
2088
  const column = columns.length === 1 ? columns[0] : null;
1410
- width -= this.columnModel.columns.filter(c => !columns.includes(c)).reduce((sum, c) => sum + c.width, 0);
2089
+ width -= this.columnModel.columns
2090
+ .filter((c) => !columns.includes(c))
2091
+ .reduce((sum, c) => sum + c.width, 0);
1411
2092
  if (column?.kind !== 'inspectorPane' && column?.kind !== 'tree') return;
1412
2093
  if (column.kind === 'inspectorPane' && this.inspector?.options?.presentation !== 'pane') return;
1413
2094
  const nextWidth = Math.max(column.minWidth, Math.floor(width));
@@ -1422,9 +2103,10 @@ export class TreeViewController {
1422
2103
  rowHeight: this.rowModel.rowHeight,
1423
2104
  indentWidth: this.rowModel.indentWidth,
1424
2105
  sort: this.columnModel.sort,
2106
+ sortValues: this.sortValueSnapshot ? Array.from(this.sortValueSnapshot) : null,
1425
2107
  filterQuery: this.filterQuery,
1426
2108
  filterOptions: this.filterOptions,
1427
- ...overrides,
2109
+ ...overrides
1428
2110
  };
1429
2111
  }
1430
2112
 
@@ -1441,12 +2123,18 @@ export class TreeViewController {
1441
2123
  const scrollY = this.viewport.scrollY;
1442
2124
  const rowIndex = Math.max(0, Math.floor(scrollY / this.rowModel.rowHeight));
1443
2125
  const row = this.rowModel.getRow(rowIndex);
1444
- if (!row) return { scrollX, scrollY, nodeId: null, offsetY: 0 };
2126
+ if (!row)
2127
+ return {
2128
+ scrollX,
2129
+ scrollY,
2130
+ nodeId: null,
2131
+ offsetY: 0
2132
+ };
1445
2133
  return {
1446
2134
  scrollX,
1447
2135
  scrollY,
1448
2136
  nodeId: row.nodeId,
1449
- offsetY: Math.max(0, scrollY - row.y),
2137
+ offsetY: Math.max(0, scrollY - row.y)
1450
2138
  };
1451
2139
  }
1452
2140
 
@@ -1460,7 +2148,11 @@ export class TreeViewController {
1460
2148
  if (!this.inspector) return;
1461
2149
  const expanded = new Set(this.expansion.model.expanded);
1462
2150
  const focusId = focusPath ? `model:${focusPath}` : this.focusedId;
1463
- const nodes = this.inspector.builder.build(this.inspector.model, this.inspector.meta, this.inspector.options);
2151
+ const nodes = this.inspector.builder.build(
2152
+ this.inspector.model,
2153
+ this.inspector.meta,
2154
+ this.inspector.options
2155
+ );
1464
2156
  this.model.setTree(nodes);
1465
2157
  this.expansion.model.expanded = expanded;
1466
2158
  if (focusId) this.expansion.expandAncestors(focusId);
@@ -1478,7 +2170,8 @@ const NATIVE_SCROLLBAR_STYLE_ID = 'virtual-tree-canvas-native-scrollbar-style';
1478
2170
 
1479
2171
  function ensureNativeScrollbarStyles(doc) {
1480
2172
  if (!doc || typeof doc.createElement !== 'function') return;
1481
- if (typeof doc.getElementById === 'function' && doc.getElementById(NATIVE_SCROLLBAR_STYLE_ID)) return;
2173
+ if (typeof doc.getElementById === 'function' && doc.getElementById(NATIVE_SCROLLBAR_STYLE_ID))
2174
+ return;
1482
2175
  const style = doc.createElement('style');
1483
2176
  style.id = NATIVE_SCROLLBAR_STYLE_ID;
1484
2177
  style.textContent = `
@@ -1545,7 +2238,11 @@ function ensureNativeScrollbarStyles(doc) {
1545
2238
  function applyNativeScrollbarTheme(elements, theme) {
1546
2239
  const colors = theme?.colors ?? {};
1547
2240
  const track = alphaHex(colors.row ?? colors.background ?? '#0b1020', 0.74);
1548
- const thumb = mixHex(colors.borderStrong ?? colors.border ?? '#334155', colors.textMuted ?? colors.focus ?? '#94a3b8', 0.55);
2241
+ const thumb = mixHex(
2242
+ colors.borderStrong ?? colors.border ?? '#334155',
2243
+ colors.textMuted ?? colors.focus ?? '#94a3b8',
2244
+ 0.55
2245
+ );
1549
2246
  const hover = mixHex(thumb, colors.focus ?? '#38bdf8', 0.32);
1550
2247
  const active = mixHex(thumb, colors.focus ?? '#38bdf8', 0.52);
1551
2248
  const highlight = alphaHex('#ffffff', 0.2);
@@ -1592,7 +2289,7 @@ function nativeScrollbarSize() {
1592
2289
  top: '-9999px',
1593
2290
  width: '100px',
1594
2291
  height: '100px',
1595
- overflow: 'scroll',
2292
+ overflow: 'scroll'
1596
2293
  });
1597
2294
  document.body?.appendChild(probe);
1598
2295
  const size = Math.max(10, probe.offsetWidth - probe.clientWidth || 14);
@@ -1612,7 +2309,6 @@ function assertNonNegativeNumber(value, name) {
1612
2309
  }
1613
2310
  }
1614
2311
 
1615
-
1616
2312
  function createDefaultArrayItem(meta = {}) {
1617
2313
  if (meta.itemFactory) return meta.itemFactory();
1618
2314
  if (meta.itemType === 'number') return 0;
@@ -1622,14 +2318,19 @@ function createDefaultArrayItem(meta = {}) {
1622
2318
  }
1623
2319
 
1624
2320
  function createSortValueSnapshot(column, nodes, dynamicState) {
1625
- return new Map(nodes.map((node) => [node.id, column.value(node, dynamicState.get(node.id) ?? {})]));
2321
+ return new Map(
2322
+ nodes.map((node) => [node.id, column.value(node, dynamicState.get(node.id) ?? {})])
2323
+ );
1626
2324
  }
1627
2325
 
1628
2326
  function compareColumnValues(column, a, b, dynamicState, snapshot = null) {
1629
2327
  const aValue = snapshot ? snapshot.get(a.id) : column.value(a, dynamicState.get(a.id) ?? {});
1630
2328
  const bValue = snapshot ? snapshot.get(b.id) : column.value(b, dynamicState.get(b.id) ?? {});
1631
2329
  if (typeof aValue === 'number' && typeof bValue === 'number') return aValue - bValue;
1632
- return String(aValue ?? '').localeCompare(String(bValue ?? ''), undefined, { numeric: true, sensitivity: 'base' });
2330
+ return String(aValue ?? '').localeCompare(String(bValue ?? ''), undefined, {
2331
+ numeric: true,
2332
+ sensitivity: 'base'
2333
+ });
1633
2334
  }
1634
2335
 
1635
2336
  function matchesFilter(node, state, path, query, options = {}) {
@@ -1642,18 +2343,12 @@ function matchesFilter(node, state, path, query, options = {}) {
1642
2343
  node.data?.key,
1643
2344
  node.data?.valueText,
1644
2345
  node.data?.meta?.description,
1645
- ...Object.keys(node.data?.meta?.options ?? {}),
2346
+ ...Object.keys(node.data?.meta?.options ?? {})
1646
2347
  ]
1647
- : [
1648
- node.id,
1649
- node.label,
1650
- node.type,
1651
- path,
1652
- ...(node.tags ?? []),
1653
- state.status,
1654
- state.value,
1655
- ];
1656
- return values.some((value) => matchesSearch(normalizeFilterValue(value, options), query, options.wholeWord));
2348
+ : [node.id, node.label, node.type, path, ...(node.tags ?? []), state.status, state.value];
2349
+ return values.some((value) =>
2350
+ matchesSearch(normalizeFilterValue(value, options), query, options.wholeWord)
2351
+ );
1657
2352
  }
1658
2353
 
1659
2354
  function normalizeFilterValue(value, options = {}) {
@@ -1694,11 +2389,22 @@ function now() {
1694
2389
 
1695
2390
  function resolveNodeIcons(nodes, resolver) {
1696
2391
  if (!resolver) return nodes;
1697
- return nodes.map(node => {
2392
+ return nodes.map((node) => {
1698
2393
  const visual = resolver(node);
1699
- if (typeof visual === 'string') return { ...node, icon: visual };
2394
+ if (typeof visual === 'string')
2395
+ return {
2396
+ ...node,
2397
+ icon: visual
2398
+ };
1700
2399
  if (!visual || typeof visual !== 'object') return node;
1701
- if ((visual.id !== undefined && visual.id !== node.id) || (visual.parentId !== undefined && visual.parentId !== node.parentId)) throw new TypeError('iconResolver cannot change node identity or parent');
1702
- return { ...node, ...visual };
2400
+ if (
2401
+ (visual.id !== undefined && visual.id !== node.id) ||
2402
+ (visual.parentId !== undefined && visual.parentId !== node.parentId)
2403
+ )
2404
+ throw new TypeError('iconResolver cannot change node identity or parent');
2405
+ return {
2406
+ ...node,
2407
+ ...visual
2408
+ };
1703
2409
  });
1704
2410
  }