From 62d0a7bcbd7e82f62c7e945099e4c0057dcfb493 Mon Sep 17 00:00:00 2001 From: Nazir T Date: Tue, 27 Oct 2026 12:27:07 +0300 Subject: [PATCH 1/4] perf(tree): keep tree-selection linear in the number of rows --- .../components/tree/tree-option.component.ts | 21 +++- .../tree/tree-selection.component.spec.ts | 116 ++++++++++++++++-- .../tree/tree-selection.component.ts | 70 ++++++----- tools/check-api-docs/baseline.json | 2 +- tools/public_api_guard/components/tree.api.md | 6 +- 5 files changed, 166 insertions(+), 49 deletions(-) diff --git a/packages/components/tree/tree-option.component.ts b/packages/components/tree/tree-option.component.ts index 2d810cdebb..6d153056db 100644 --- a/packages/components/tree/tree-option.component.ts +++ b/packages/components/tree/tree-option.component.ts @@ -19,8 +19,10 @@ import { InjectionToken, input, linkedSignal, + NgZone, output, QueryList, + Renderer2, signal, untracked, viewChild, @@ -73,6 +75,10 @@ export interface KbqTreeOptionParent { selectionModel: SelectionModel; unorderedOptions: QueryList>; setSelectedOptionsByClick(option: KbqTreeOption, shiftKey: boolean, ctrlKey: boolean): void; + /** Receives the focus of every option: one stream for the tree instead of a subscription per option. */ + reportOptionFocus(event: KbqTreeOptionEvent): void; + /** Receives the blur of every option. */ + reportOptionBlur(event: KbqTreeOptionEvent): void; } /** Injection token used to provide the parent component to options. */ @@ -133,7 +139,6 @@ const samePredicates = (a: TreeOptionPredicates, b: TreeOptionPredicates): boole '[attr.aria-checked]': 'ariaChecked', '[attr.aria-selected]': 'ariaSelected', '(focusin)': 'focus()', - '(mouseenter)': 'onMouseenter()', '(blur)': 'blur()', '(click)': 'selectViaInteraction($event)', '(keydown)': 'onKeydown($event)' @@ -144,6 +149,8 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte private changeDetectorRef = inject(ChangeDetectorRef); private readonly environmentInjector = inject(EnvironmentInjector); private readonly destroyRef = inject(DestroyRef); + private readonly ngZone = inject(NgZone); + private readonly renderer = inject(Renderer2); // Intersected with the rendering base because `KbqTreeNode` resolves its level and expansion state // through it; the option itself only ever touches the `KbqTreeOptionParent` half. override tree: KbqTreeOptionParent & KbqTreeBase; @@ -384,6 +391,14 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte super(); this.tree = tree; + + // Not a host listener: one would check the views on every row the pointer crosses, and only a tree in a select + // reacts to it. + const unlisten = this.ngZone.runOutsideAngular(() => + this.renderer.listen(this.elementRef.nativeElement, 'mouseenter', () => this.onMouseenter()) + ); + + this.destroyRef.onDestroy(unlisten); } ngDoCheck(): void { @@ -506,6 +521,7 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte if (!this.hasFocus) { this.focusEvents.next({ option: this }); + this.tree.reportOptionFocus({ option: this }); Promise.resolve().then(() => { this.hasFocus = true; @@ -537,6 +553,7 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte this.hasFocus = false; this.blurEvents.next({ option: this }); + this.tree.reportOptionBlur({ option: this }); }, // The environment injector, not the option's: an option removed while focused is blurred as its view is // destroyed, and the tree still has to hear about it to move focus. @@ -638,7 +655,7 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte protected onMouseenter() { if (this.disabled || !this.tree.inSelect) return; - this.focus('mouse'); + this.ngZone.run(() => this.focus('mouse')); } /** Recomputes the option's own checkbox state, leaving its ancestors as they are. */ diff --git a/packages/components/tree/tree-selection.component.spec.ts b/packages/components/tree/tree-selection.component.spec.ts index 49c483ff13..27d05bd982 100644 --- a/packages/components/tree/tree-selection.component.spec.ts +++ b/packages/components/tree/tree-selection.component.spec.ts @@ -3057,22 +3057,16 @@ describe('KbqTreeSelection', () => { expect(treeElement.querySelectorAll('.kbq-tree-option').length).toBe(0); }); - it('should drop the per-option focus subscriptions', async () => { - vi.useFakeTimers(); - + it('should stop listening to the focus of its options', () => { const tree = component.tree(); - tree.onKeyDown(createKeyboardEvent('keydown', DOWN_ARROW)); - fixture.detectChanges(); - await vi.runOnlyPendingTimersAsync(); - - expect(tree['optionFocusSubscription']).not.toBeNull(); - expect(tree['optionBlurSubscription']).not.toBeNull(); + expect(tree['optionFocusEvents'].observed).toBe(true); + expect(tree['optionBlurEvents'].observed).toBe(true); fixture.destroy(); - expect(tree['optionFocusSubscription']).toBeNull(); - expect(tree['optionBlurSubscription']).toBeNull(); + expect(tree['optionFocusEvents'].observed).toBe(false); + expect(tree['optionBlurEvents'].observed).toBe(false); }); it('should not restore the tab index after the tree is gone', () => { @@ -5747,4 +5741,104 @@ describe('KbqTreeSelection rendering a change of the data', () => { expect(selectAllCheckbox().classList).toContain('kbq-indeterminate'); }); + + it('should leave every view unchecked when the pointer enters a row of a tree outside a select', () => { + const row = getNodeByText(fixture.nativeElement, 'Pictures'); + const checks = countChecks(() => dispatchFakeEvent(row, 'mouseenter')); + + expect(checks.declaringView).toBe(0); + expect(checks.rows).toEqual(checks.rows.map(() => 0)); + }); + + it('should hand the focus of a row the change brings in to the keyboard', async () => { + fixture.componentInstance.selectAll = false; + await settle(); + + const { tree, treeControl } = fixture.componentInstance; + + treeControl.expand(node('Documents')); + await settle(); + dispatchFakeEvent(getNodeByText(fixture.nativeElement, 'angular'), 'focusin'); + + expect(tree().keyManager.activeItem!.value).toBe('angular'); + }); + + it('should report through optionFocusChanges the focus of rows rendered after subscribing', async () => { + const { tree, treeControl } = fixture.componentInstance; + const focused: string[] = []; + const subscription = tree().optionFocusChanges.subscribe(({ option }) => focused.push(option.value)); + + treeControl.expand(node('Documents')); + await settle(); + getNodeByText(fixture.nativeElement, 'angular').focus(); + subscription.unsubscribe(); + + expect(focused).toEqual(['angular']); + }); +}); + +@Component({ + imports: [KbqTreeModule], + template: ` + + {{ node.name }} + + ` +}) +class TreeWithSharedValues extends TreeParams { + readonly tree = viewChild.required>(KbqTreeSelection); + + constructor() { + super(); + + // Rows share values (`jpg`, `ts`, `pdf`, `app`): only the node tells such rows apart. + this.treeControl = new FlatTreeControl( + this.getLevel, + this.isExpandable, + (node: FileFlatNode) => node.type ?? node.name, + this.getValue + ); + this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener); + this.dataSource.data = this.treeData; + } +} + +describe('KbqTreeSelection keyboard order', () => { + let fixture: ComponentFixture; + + const node = (name: string): FileFlatNode => + fixture.componentInstance.treeControl.dataNodes.find((dataNode) => dataNode.name === name)!; + + /** The rows the keyboard moves through, in its order. */ + const keyboardRows = (): HTMLElement[] => + fixture.componentInstance.tree().renderedOptions.map((option) => option.getHostElement()); + + beforeEach(() => { + fixture = TestBed.createComponent(TreeWithSharedValues); + fixture.detectChanges(); + }); + + it('should follow the rows on screen across expanding, collapsing and new data, with values shared by rows', () => { + const { dataSource, treeControl } = fixture.componentInstance; + + treeControl.expand(node('Pictures')); + treeControl.expand(node('Documents')); + TestBed.tick(); + + expect(getNodes(fixture.nativeElement).length).toBe(11); + expect(keyboardRows()).toEqual(getNodes(fixture.nativeElement)); + + treeControl.collapse(node('Pictures')); + TestBed.tick(); + + expect(getNodes(fixture.nativeElement).length).toBe(8); + expect(keyboardRows()).toEqual(getNodes(fixture.nativeElement)); + + dataSource.data = buildFileTree(DATA_OBJECT, 0); + treeControl.expand(node('Downloads')); + TestBed.tick(); + + expect(getNodes(fixture.nativeElement).length).toBe(8); + expect(keyboardRows()).toEqual(getNodes(fixture.nativeElement)); + }); }); diff --git a/packages/components/tree/tree-selection.component.ts b/packages/components/tree/tree-selection.component.ts index 4acc612a3e..6bbc5dfce2 100644 --- a/packages/components/tree/tree-selection.component.ts +++ b/packages/components/tree/tree-selection.component.ts @@ -68,7 +68,7 @@ import { UP_ARROW } from '@koobiq/components/core'; import { KBQ_FORM_FIELD } from '@koobiq/components/form-field'; -import { merge, Observable, Subscription } from 'rxjs'; +import { Observable, Subject, Subscription } from 'rxjs'; import { AsyncScheduler } from 'rxjs/internal/scheduler/AsyncScheduler'; import { delay } from 'rxjs/operators'; import { FlatTreeControl } from './control/flat-tree-control'; @@ -336,12 +336,20 @@ export class KbqTreeSelection this.autoSelectState.set(coerceBooleanProperty(value)); } + // One stream the options report to: merging every option's own stream on each render was linear in the rows, + // and missed the rows rendered after a subscription. + private readonly optionFocusEvents = new Subject(); + + private readonly optionBlurEvents = new Subject(); + + /** Emits when an option of the tree takes focus. */ get optionFocusChanges(): Observable { - return merge(...this.renderedOptions.map((option) => option.onFocus)); + return this.optionFocusEvents.asObservable(); } + /** Emits when an option of the tree loses focus. */ get optionBlurChanges(): Observable { - return merge(...this.renderedOptions.map((option) => option.onBlur)); + return this.optionBlurEvents.asObservable(); } /** @@ -570,10 +578,6 @@ export class KbqTreeSelection return this.sortedNodes.length; } - private optionFocusSubscription: Subscription | null; - - private optionBlurSubscription: Subscription | null; - private selectionModelSubscription: Subscription | null = null; /** Whether a value report is queued for the end of the current tick. Cleared by `writeValue` to cancel it. */ @@ -865,11 +869,11 @@ export class KbqTreeSelection this.bindSelectionModel(); + this.listenToOptionsFocus(); + this.renderedOptions.changes .pipe(delay(0, this.scheduler), takeUntilDestroyed(this.destroyRef)) .subscribe((options) => { - this.resetOptions(); - // Check to see if we need to update our tab index this.updateTabIndex(); @@ -925,8 +929,6 @@ export class KbqTreeSelection ngOnDestroy(): void { super.ngOnDestroy(); - this.dropSubscriptions(); - this.focusMonitor.stopMonitoring(this.elementRef); } @@ -1107,6 +1109,16 @@ export class KbqTreeSelection } } + /** @docs-private */ + reportOptionFocus(event: KbqTreeOptionEvent): void { + this.optionFocusEvents.next(event); + } + + /** @docs-private */ + reportOptionBlur(event: KbqTreeOptionEvent): void { + this.optionBlurEvents.next(event); + } + /** @docs-private */ setSelectedOptionsByClick(option: KbqTreeOption, shiftKey: boolean, ctrlKey: boolean): void { if (option.disabled || !option.selectable()) { @@ -1596,12 +1608,21 @@ export class KbqTreeSelection orderedOptions.push(selectAllOption); } + // Indexed once rather than searched per node, which was quadratic in the rows. The first option wins in both + // maps, as the search did. + const optionsByNode = new Map>(); + const optionsByValue = new Map>(); + + this.unorderedOptions.forEach((option) => { + if (!optionsByNode.has(option.data)) optionsByNode.set(option.data, option); + + if (!optionsByValue.has(option.value)) optionsByValue.set(option.value, option); + }); + this.sortedNodes.forEach((node) => { // By node first: nodes do not have to carry distinct values, and a value match would hand every node with // the same value the first option holding it. - const found = - this.unorderedOptions.find((option) => option.data === node) ?? - this.unorderedOptions.find((option) => option.value === this.treeControl.getValue(node)); + const found = optionsByNode.get(node) ?? optionsByValue.get(this.treeControl.getValue(node)); if (found) { orderedOptions.push(found); @@ -1626,25 +1647,8 @@ export class KbqTreeSelection return array; } - private resetOptions() { - this.dropSubscriptions(); - this.listenToOptionsFocus(); - } - - private dropSubscriptions() { - if (this.optionFocusSubscription) { - this.optionFocusSubscription.unsubscribe(); - this.optionFocusSubscription = null; - } - - if (this.optionBlurSubscription) { - this.optionBlurSubscription.unsubscribe(); - this.optionBlurSubscription = null; - } - } - private listenToOptionsFocus(): void { - this.optionFocusSubscription = this.optionFocusChanges.subscribe((event) => { + this.optionFocusEvents.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { const index: number = this.renderedOptions.toArray().indexOf(event.option as KbqTreeOption); this.renderedOptions.filter((option) => option.hasFocus).forEach((option) => (option.hasFocus = false)); @@ -1659,7 +1663,7 @@ export class KbqTreeSelection // set, because `KbqTreeOption.hasFocus` is cleared synchronously but only raised a microtask later, so // `hasFocusedOption()` reports nothing focused for the whole move. Only a blur of the option the // key manager still points at is the focus actually leaving the options. - this.optionBlurSubscription = this.optionBlurChanges.subscribe(({ option }) => { + this.optionBlurEvents.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ option }) => { if (option === this.keyManager.activeItem && !this.hasFocusedOption() && this.resetFocusedItemOnBlur) { this.keyManager.setActiveItem(-1); } diff --git a/tools/check-api-docs/baseline.json b/tools/check-api-docs/baseline.json index e05f766401..60b050d218 100644 --- a/tools/check-api-docs/baseline.json +++ b/tools/check-api-docs/baseline.json @@ -44,7 +44,7 @@ "components-toast": 50, "components-toggle": 16, "components-tooltip": 22, - "components-tree": 157, + "components-tree": 155, "components-tree-select": 28, "components-username": 1 } diff --git a/tools/public_api_guard/components/tree.api.md b/tools/public_api_guard/components/tree.api.md index eae3af8a99..17de64e01c 100644 --- a/tools/public_api_guard/components/tree.api.md +++ b/tools/public_api_guard/components/tree.api.md @@ -546,6 +546,8 @@ export interface KbqTreeOptionParent { inSelect: boolean; multiple: boolean; optionShouldHoldFocusOnBlur: boolean; + reportOptionBlur(event: KbqTreeOptionEvent): void; + reportOptionFocus(event: KbqTreeOptionEvent): void; selectAllState: KbqPseudoCheckboxState; // (undocumented) selectionModel: SelectionModel; @@ -628,9 +630,7 @@ export class KbqTreeSelection extends KbqTreeBase, T, K>>; onTouched: () => void; - // (undocumented) get optionBlurChanges(): Observable; - // (undocumented) get optionFocusChanges(): Observable; // (undocumented) optionShouldHoldFocusOnBlur: boolean; @@ -642,6 +642,8 @@ export class KbqTreeSelection extends KbqTreeBase>; renderNodeChanges(data: T[] | ReadonlyArray, dataDiffer?: IterableDiffer, viewContainer?: ViewContainerRef, parentData?: T): void; + reportOptionBlur(event: KbqTreeOptionEvent): void; + reportOptionFocus(event: KbqTreeOptionEvent): void; // (undocumented) resetFocusedItemOnBlur: boolean; protected get resolvedAriaLabel(): string | null; From a7129b555f4e0ac9e24da4731c180b5261b2afde Mon Sep 17 00:00:00 2001 From: Nazir T Date: Tue, 27 Oct 2026 15:50:07 +0300 Subject: [PATCH 2/4] perf(tree): match rendered rows against a set of the selected values (#DS-5796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-rendering the rows searched the selected values once per row, costing rows × selected. The values now go into a Set. The tree also completes optionFocusChanges and optionBlurChanges on destroy, so their subscribers complete with it. --- .../tree/tree-selection.component.spec.ts | 35 +++++++++++++++++++ .../tree/tree-selection.component.ts | 7 ++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/components/tree/tree-selection.component.spec.ts b/packages/components/tree/tree-selection.component.spec.ts index 27d05bd982..5aa6a1e014 100644 --- a/packages/components/tree/tree-selection.component.spec.ts +++ b/packages/components/tree/tree-selection.component.spec.ts @@ -3069,6 +3069,19 @@ describe('KbqTreeSelection', () => { expect(tree['optionBlurEvents'].observed).toBe(false); }); + it('should complete optionFocusChanges and optionBlurChanges', () => { + const tree = component.tree(); + const focusCompleted = vi.fn(); + const blurCompleted = vi.fn(); + + tree.optionFocusChanges.subscribe({ complete: focusCompleted }); + tree.optionBlurChanges.subscribe({ complete: blurCompleted }); + fixture.destroy(); + + expect(focusCompleted).toHaveBeenCalledTimes(1); + expect(blurCompleted).toHaveBeenCalledTimes(1); + }); + it('should not restore the tab index after the tree is gone', () => { vi.useFakeTimers(); @@ -5697,6 +5710,28 @@ describe('KbqTreeSelection rendering a change of the data', () => { expect(checks.rows).toEqual([1, 1, 1, 1, 1]); }); + // Searching the selected values for every row made a render cost rows × selected. + it('should read the selected values once for a render of the rows, not once per row', async () => { + const { tree, treeControl } = fixture.componentInstance; + const selectedValues = tree().getSelectedValues(); + let reads = 0; + const countedValues = new Proxy(selectedValues, { + get: (target, key, receiver) => { + if (typeof key === 'string' && /^\d+$/.test(key)) reads++; + + return Reflect.get(target, key, receiver); + } + }); + + vi.spyOn(tree(), 'getSelectedValues').mockReturnValue(countedValues); + + treeControl.expand(node('Documents')); + await settle(); + + expect(getNodes(fixture.nativeElement).length).toBeGreaterThan(5); + expect(reads).toBeLessThanOrEqual(selectedValues.length); + }); + it('should hand the rows of data that arrives outside change detection to the keyboard', async () => { fixture.componentInstance.selectAll = false; await settle(); diff --git a/packages/components/tree/tree-selection.component.ts b/packages/components/tree/tree-selection.component.ts index 6bbc5dfce2..f4eecf819e 100644 --- a/packages/components/tree/tree-selection.component.ts +++ b/packages/components/tree/tree-selection.component.ts @@ -879,10 +879,10 @@ export class KbqTreeSelection this.syncSelectionModelToDataNodes(); - const selectedValues = this.multiple ? this.getSelectedValues() : [this.getSelectedValues()]; + const selectedValues = new Set(this.multiple ? this.getSelectedValues() : [this.getSelectedValues()]); options.forEach((option) => { - if (selectedValues.includes(option.value)) { + if (selectedValues.has(option.value)) { option.select(false); } else { option.deselect(); @@ -929,6 +929,9 @@ export class KbqTreeSelection ngOnDestroy(): void { super.ngOnDestroy(); + this.optionFocusEvents.complete(); + this.optionBlurEvents.complete(); + this.focusMonitor.stopMonitoring(this.elementRef); } From 4f90aa54d23f35ebc00c4d1d7fdae1d35d73caaf Mon Sep 17 00:00:00 2001 From: lskramarov Date: Wed, 28 Oct 2026 12:57:58 +0300 Subject: [PATCH 3/4] perf(tree): drop the remaining quadratic work from the option sweep (#DS-5796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FlatTreeControl indexes the positions of its data nodes, so getDescendants and getParents no longer search the array for every rendered row: a tri-state tree paid rows × nodes on each sweep. - select() and deselect() recompute the option's own checkbox state only: the ancestors read the selection model, which neither of them changes. - The notifications of one task share one sweep instead of one sweep each. - reportOptionFocus and reportOptionBlur are optional, so other implementations of KbqTreeOptionParent keep compiling. - Hovering the option that already holds focus no longer re-enters the zone. - Specs: the focus of a newly rendered row is checked before the sweep, the spec reading the private subjects is gone, and new specs count the work of a sweep. --- .../tree/control/flat-tree-control.spec.ts | 42 +++++++++ .../tree/control/flat-tree-control.ts | 30 +++++-- .../components/tree/tree-option.component.ts | 29 +++--- .../tree/tree-selection.component.spec.ts | 90 +++++++++++++++---- .../tree/tree-selection.component.ts | 29 ++++-- tools/public_api_guard/components/tree.api.md | 4 +- 6 files changed, 183 insertions(+), 41 deletions(-) diff --git a/packages/components/tree/control/flat-tree-control.spec.ts b/packages/components/tree/control/flat-tree-control.spec.ts index b883e3cf15..1c10880083 100644 --- a/packages/components/tree/control/flat-tree-control.spec.ts +++ b/packages/components/tree/control/flat-tree-control.spec.ts @@ -192,6 +192,48 @@ describe('FlatTreeControl', () => { }); }); + describe('position of a node', () => { + const names = (found: FlatNode[]): string[] => found.map((node) => node.name); + + it('should search dataNodes once for any number of lookups', () => { + const indexOf = vi.spyOn(nodes, 'indexOf'); + + nodes.forEach((node) => { + control.getDescendants(node); + control.getParents(node); + }); + + expect(indexOf).toHaveBeenCalledTimes(1); + }); + + it('should follow dataNodes changed in place', () => { + const notes: FlatNode = { name: 'notes', level: 2, expandable: false }; + + expect(names(control.getDescendants(byName(nodes, 'documents')))).toEqual(['specs', 'draft', 'readme']); + + nodes.splice(1, 0, { name: 'archive', level: 1, expandable: false }); + nodes.splice(3, 0, notes); + + expect(names(control.getDescendants(byName(nodes, 'documents')))).toEqual([ + 'notes', + 'specs', + 'draft', + 'readme' + ]); + expect(names(control.getParents(notes))).toEqual(['root', 'documents']); + expect(names(control.getParents(byName(nodes, 'draft')))).toEqual(['root', 'documents', 'specs']); + }); + + it('should follow a replaced dataNodes array', () => { + expect(names(control.getDescendants(byName(nodes, 'documents')))).toEqual(['specs', 'draft', 'readme']); + + control.dataNodes = nodes.filter((node) => node.name !== 'specs' && node.name !== 'draft'); + + expect(names(control.getDescendants(byName(nodes, 'documents')))).toEqual(['readme']); + expect(control.getParents(byName(nodes, 'draft'))).toEqual([]); + }); + }); + describe('hasValue', () => { it('should find the node carrying the value', () => { expect(control.hasValue('specs')).toBe(byName(nodes, 'specs')); diff --git a/packages/components/tree/control/flat-tree-control.ts b/packages/components/tree/control/flat-tree-control.ts index bb4bd0d882..bb767c2a8d 100644 --- a/packages/components/tree/control/flat-tree-control.ts +++ b/packages/components/tree/control/flat-tree-control.ts @@ -17,6 +17,10 @@ export class FlatTreeControl extends BaseTreeControl { private filters: FlatTreeControlFilter[]; + // Positions in `dataNodes`: a tree looks up every rendered row, and `indexOf` would make that rows × nodes. + private indexedNodes: T[] | null = null; + private nodeIndexes = new Map(); + /** Construct with flat tree data node functions getLevel, isExpandable, getValue and getViewValue. */ constructor( public getLevel: (dataNode: T) => number, @@ -165,20 +169,36 @@ export class FlatTreeControl extends BaseTreeControl { } /** - * Position of the node in `dataNodes`. With `trackBy` a node rebuilt as a new object since is found by its key, - * as the expansion methods accept it too. + * Position of the node in `dataNodes`, constant-time while the index matches the array. With `trackBy` a node + * rebuilt as a new object since is found by its key, as the expansion methods accept it too. */ private indexOfNode(node: T): number { const { dataNodes, trackBy } = this; if (!dataNodes) return -1; + const cached = this.indexedNodes === dataNodes ? this.nodeIndexes.get(node) : undefined; + + // Checked against the array: `dataNodes` can be mutated in place. + if (cached !== undefined && dataNodes[cached] === node) return cached; + const index = dataNodes.indexOf(node); - if (index !== -1 || !trackBy) return index; + // A node that is not there leaves the index as it is. + if (index === -1) { + if (!trackBy) return index; + + const key = trackBy(node); - const key = trackBy(node); + return dataNodes.findIndex((candidate) => trackBy(candidate) === key); + } + + this.indexedNodes = dataNodes; + this.nodeIndexes = new Map(); + dataNodes.forEach((dataNode, position) => { + if (!this.nodeIndexes.has(dataNode)) this.nodeIndexes.set(dataNode, position); + }); - return dataNodes.findIndex((candidate) => trackBy(candidate) === key); + return index; } } diff --git a/packages/components/tree/tree-option.component.ts b/packages/components/tree/tree-option.component.ts index 6d153056db..fe0c2ad196 100644 --- a/packages/components/tree/tree-option.component.ts +++ b/packages/components/tree/tree-option.component.ts @@ -75,10 +75,10 @@ export interface KbqTreeOptionParent { selectionModel: SelectionModel; unorderedOptions: QueryList>; setSelectedOptionsByClick(option: KbqTreeOption, shiftKey: boolean, ctrlKey: boolean): void; - /** Receives the focus of every option: one stream for the tree instead of a subscription per option. */ - reportOptionFocus(event: KbqTreeOptionEvent): void; - /** Receives the blur of every option. */ - reportOptionBlur(event: KbqTreeOptionEvent): void; + /** Called when an option takes focus. */ + reportOptionFocus?(event: KbqTreeOptionEvent): void; + /** Called when an option loses focus. */ + reportOptionBlur?(event: KbqTreeOptionEvent): void; } /** Injection token used to provide the parent component to options. */ @@ -520,8 +520,10 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte kbqFocusAndReveal(this.elementRef.nativeElement, focusOrigin === 'mouse'); if (!this.hasFocus) { - this.focusEvents.next({ option: this }); - this.tree.reportOptionFocus({ option: this }); + const event: KbqTreeOptionEvent = { option: this }; + + this.focusEvents.next(event); + this.tree.reportOptionFocus?.(event); Promise.resolve().then(() => { this.hasFocus = true; @@ -552,8 +554,10 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte this.hasFocus = false; - this.blurEvents.next({ option: this }); - this.tree.reportOptionBlur({ option: this }); + const event: KbqTreeOptionEvent = { option: this }; + + this.blurEvents.next(event); + this.tree.reportOptionBlur?.(event); }, // The environment injector, not the option's: an option removed while focused is blurred as its view is // destroyed, and the tree still has to hear about it to move focus. @@ -590,7 +594,8 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte this.focus(); } - this.updateCheckboxState(); + // Its own state only: an ancestor's reads the selection model, which this leaves as it is. + this.syncCheckboxState(); this.emitSelectionChangeEvent(); }); } @@ -603,7 +608,8 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte this.selectedState.set(false); - this.updateCheckboxState(); + // Its own state only, as in `select`. + this.syncCheckboxState(); this.emitSelectionChangeEvent(); }); } @@ -655,6 +661,9 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte protected onMouseenter() { if (this.disabled || !this.tree.inSelect) return; + // Focusing the option it already holds would only run change detection. + if (this.hasFocus && _getFocusedElementPierceShadowDom() === this.elementRef.nativeElement) return; + this.ngZone.run(() => this.focus('mouse')); } diff --git a/packages/components/tree/tree-selection.component.spec.ts b/packages/components/tree/tree-selection.component.spec.ts index 5aa6a1e014..88dd70d3d5 100644 --- a/packages/components/tree/tree-selection.component.spec.ts +++ b/packages/components/tree/tree-selection.component.spec.ts @@ -9,6 +9,7 @@ import { effect, EventEmitter, InputSignal, + NgZone, signal, Type, viewChild, @@ -2400,6 +2401,59 @@ describe('KbqTreeSelection', () => { expect(getNodes(treeElement).length).toBe(component.treeControl.dataNodes.length); expect(rollUps).not.toHaveBeenCalled(); }); + + it('should not roll the selected rows a render brings in up through their ancestors', async () => { + vi.useFakeTimers(); + + const { tree, treeControl } = component; + + tree().selectionModel.select( + treeControl.dataNodes.find((node) => node.name === `node-${DEEP_CHAIN_DEPTH}`)! + ); + await settle(); + + const rollUps = vi.spyOn(treeControl, 'getParents'); + + treeControl.expandAll(); + await settle(); + + expect(rollUps).not.toHaveBeenCalled(); + expect(optionFor(`node-${DEEP_CHAIN_DEPTH}`).selected).toBe(true); + expect(optionFor('node-0').checkboxState).toBe('indeterminate'); + }); + + // A search of the data nodes for each branch a sweep recomputes would make it cost rows × nodes. + it('should not search the data nodes for the rows it sweeps', async () => { + vi.useFakeTimers(); + + component.treeControl.expandAll(); + await settle(); + + const indexOf = vi.spyOn(component.treeControl.dataNodes, 'indexOf'); + + dispatchEvent(getNodeByText(treeElement, `node-${DEEP_CHAIN_DEPTH}`), createMouseEvent('click')); + await settle(); + + expect(indexOf).not.toHaveBeenCalled(); + }); + + it('should sweep the rows once for the selection changes of one task', async () => { + vi.useFakeTimers(); + + const { tree, treeControl } = component; + + treeControl.expandAll(); + await settle(); + + const sweeps = vi.spyOn(optionFor('node-0'), 'markForCheck'); + + ['leaf-1', 'leaf-2', 'leaf-3'].forEach((name) => + tree().selectionModel.select(treeControl.dataNodes.find((node) => node.name === name)!) + ); + await settle(); + + expect(sweeps).toHaveBeenCalledTimes(1); + }); }); describe('disabled options', () => { @@ -3057,18 +3111,6 @@ describe('KbqTreeSelection', () => { expect(treeElement.querySelectorAll('.kbq-tree-option').length).toBe(0); }); - it('should stop listening to the focus of its options', () => { - const tree = component.tree(); - - expect(tree['optionFocusEvents'].observed).toBe(true); - expect(tree['optionBlurEvents'].observed).toBe(true); - - fixture.destroy(); - - expect(tree['optionFocusEvents'].observed).toBe(false); - expect(tree['optionBlurEvents'].observed).toBe(false); - }); - it('should complete optionFocusChanges and optionBlurChanges', () => { const tree = component.tree(); const focusCompleted = vi.fn(); @@ -5710,7 +5752,7 @@ describe('KbqTreeSelection rendering a change of the data', () => { expect(checks.rows).toEqual([1, 1, 1, 1, 1]); }); - // Searching the selected values for every row made a render cost rows × selected. + // A search of the selected values per row would make a render cost rows × selected. it('should read the selected values once for a render of the rows, not once per row', async () => { const { tree, treeControl } = fixture.componentInstance; const selectedValues = tree().getSelectedValues(); @@ -5785,14 +5827,32 @@ describe('KbqTreeSelection rendering a change of the data', () => { expect(checks.rows).toEqual(checks.rows.map(() => 0)); }); - it('should hand the focus of a row the change brings in to the keyboard', async () => { + it('should leave the zone alone when the pointer enters the row a tree in a select has focused', async () => { + const row = getNodeByText(fixture.nativeElement, 'Pictures'); + + fixture.componentInstance.tree().inSelect = true; + row.focus(); + await settle(); + + const run = vi.spyOn(TestBed.inject(NgZone), 'run'); + + dispatchFakeEvent(row, 'mouseenter'); + + expect(run).not.toHaveBeenCalled(); + + dispatchFakeEvent(getNodeByText(fixture.nativeElement, 'Documents'), 'mouseenter'); + + expect(run).toHaveBeenCalled(); + }); + + it('should hand the focus of a row the change brings in to the keyboard before the rows are swept', async () => { fixture.componentInstance.selectAll = false; await settle(); const { tree, treeControl } = fixture.componentInstance; treeControl.expand(node('Documents')); - await settle(); + TestBed.tick(); dispatchFakeEvent(getNodeByText(fixture.nativeElement, 'angular'), 'focusin'); expect(tree().keyManager.activeItem!.value).toBe('angular'); diff --git a/packages/components/tree/tree-selection.component.ts b/packages/components/tree/tree-selection.component.ts index f4eecf819e..e332194f4c 100644 --- a/packages/components/tree/tree-selection.component.ts +++ b/packages/components/tree/tree-selection.component.ts @@ -70,7 +70,7 @@ import { import { KBQ_FORM_FIELD } from '@koobiq/components/form-field'; import { Observable, Subject, Subscription } from 'rxjs'; import { AsyncScheduler } from 'rxjs/internal/scheduler/AsyncScheduler'; -import { delay } from 'rxjs/operators'; +import { delay, filter, map } from 'rxjs/operators'; import { FlatTreeControl } from './control/flat-tree-control'; import { KbqTreeNodeOutlet } from './outlet'; import { KbqTreeNodePadding } from './padding.directive'; @@ -336,8 +336,7 @@ export class KbqTreeSelection this.autoSelectState.set(coerceBooleanProperty(value)); } - // One stream the options report to: merging every option's own stream on each render was linear in the rows, - // and missed the rows rendered after a subscription. + // Options report here, so a subscriber also hears the rows rendered after it subscribed. private readonly optionFocusEvents = new Subject(); private readonly optionBlurEvents = new Subject(); @@ -871,9 +870,21 @@ export class KbqTreeSelection this.listenToOptionsFocus(); + // A render and the selection changes of one task each notify: the first sweep reads them all, and the others are + // dropped. Each notification keeps its own timer, where `auditTime` would tie the later ones to the first one's. + let requestedSweeps = 0; + let sweptRequests = 0; + this.renderedOptions.changes - .pipe(delay(0, this.scheduler), takeUntilDestroyed(this.destroyRef)) - .subscribe((options) => { + .pipe( + map((options) => ({ options, request: ++requestedSweeps })), + delay(0, this.scheduler), + filter(({ request }) => request > sweptRequests), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(({ options }) => { + sweptRequests = requestedSweeps; + // Check to see if we need to update our tab index this.updateTabIndex(); @@ -1611,8 +1622,7 @@ export class KbqTreeSelection orderedOptions.push(selectAllOption); } - // Indexed once rather than searched per node, which was quadratic in the rows. The first option wins in both - // maps, as the search did. + // Indexed once per render, as a search per node is quadratic in the rows. The first option wins in both maps. const optionsByNode = new Map>(); const optionsByValue = new Map>(); @@ -1651,7 +1661,8 @@ export class KbqTreeSelection } private listenToOptionsFocus(): void { - this.optionFocusEvents.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { + // Both streams complete with the tree. + this.optionFocusEvents.subscribe((event) => { const index: number = this.renderedOptions.toArray().indexOf(event.option as KbqTreeOption); this.renderedOptions.filter((option) => option.hasFocus).forEach((option) => (option.hasFocus = false)); @@ -1666,7 +1677,7 @@ export class KbqTreeSelection // set, because `KbqTreeOption.hasFocus` is cleared synchronously but only raised a microtask later, so // `hasFocusedOption()` reports nothing focused for the whole move. Only a blur of the option the // key manager still points at is the focus actually leaving the options. - this.optionBlurEvents.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ option }) => { + this.optionBlurEvents.subscribe(({ option }) => { if (option === this.keyManager.activeItem && !this.hasFocusedOption() && this.resetFocusedItemOnBlur) { this.keyManager.setActiveItem(-1); } diff --git a/tools/public_api_guard/components/tree.api.md b/tools/public_api_guard/components/tree.api.md index 17de64e01c..58d0bd19b8 100644 --- a/tools/public_api_guard/components/tree.api.md +++ b/tools/public_api_guard/components/tree.api.md @@ -546,8 +546,8 @@ export interface KbqTreeOptionParent { inSelect: boolean; multiple: boolean; optionShouldHoldFocusOnBlur: boolean; - reportOptionBlur(event: KbqTreeOptionEvent): void; - reportOptionFocus(event: KbqTreeOptionEvent): void; + reportOptionBlur?(event: KbqTreeOptionEvent): void; + reportOptionFocus?(event: KbqTreeOptionEvent): void; selectAllState: KbqPseudoCheckboxState; // (undocumented) selectionModel: SelectionModel; From 7b9424ebc241e1c72a0a55743c6d3b173964c8c7 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Wed, 28 Oct 2026 15:59:18 +0300 Subject: [PATCH 4/4] fix(tree): mark swept rows by node and keep projected tri-state checkboxes current (#DS-5796) - The sweep marks a row selected by its node, not by its value: rows that share a value, or carry none, no longer show as selected when another node is. - A projected kbq-pseudo-checkbox keeps its tri-state while the tree hides its own checkbox. - FlatTreeControl keys its position index weakly by the dataNodes array, so replaced data is released. - The sweep coalescing uses one counter; a stale comment and overlapping specs are cleaned up. --- .../tree/control/flat-tree-control.ts | 15 +- .../components/tree/tree-option.component.ts | 9 +- .../tree/tree-selection.component.spec.ts | 176 +++++++++++------- .../tree/tree-selection.component.ts | 22 +-- 4 files changed, 130 insertions(+), 92 deletions(-) diff --git a/packages/components/tree/control/flat-tree-control.ts b/packages/components/tree/control/flat-tree-control.ts index bb767c2a8d..1e23914ac7 100644 --- a/packages/components/tree/control/flat-tree-control.ts +++ b/packages/components/tree/control/flat-tree-control.ts @@ -17,9 +17,9 @@ export class FlatTreeControl extends BaseTreeControl { private filters: FlatTreeControlFilter[]; - // Positions in `dataNodes`: a tree looks up every rendered row, and `indexOf` would make that rows × nodes. - private indexedNodes: T[] | null = null; - private nodeIndexes = new Map(); + // Positions in each `dataNodes` array: a tree looks up every rendered row, and `indexOf` would make that + // rows × nodes. Keyed weakly by the array, so replacing `dataNodes` releases the old nodes. + private readonly nodeIndexes = new WeakMap>(); /** Construct with flat tree data node functions getLevel, isExpandable, getValue and getViewValue. */ constructor( @@ -177,7 +177,7 @@ export class FlatTreeControl extends BaseTreeControl { if (!dataNodes) return -1; - const cached = this.indexedNodes === dataNodes ? this.nodeIndexes.get(node) : undefined; + const cached = this.nodeIndexes.get(dataNodes)?.get(node); // Checked against the array: `dataNodes` can be mutated in place. if (cached !== undefined && dataNodes[cached] === node) return cached; @@ -193,11 +193,12 @@ export class FlatTreeControl extends BaseTreeControl { return dataNodes.findIndex((candidate) => trackBy(candidate) === key); } - this.indexedNodes = dataNodes; - this.nodeIndexes = new Map(); + const positions = new Map(); + dataNodes.forEach((dataNode, position) => { - if (!this.nodeIndexes.has(dataNode)) this.nodeIndexes.set(dataNode, position); + if (!positions.has(dataNode)) positions.set(dataNode, position); }); + this.nodeIndexes.set(dataNodes, positions); return index; } diff --git a/packages/components/tree/tree-option.component.ts b/packages/components/tree/tree-option.component.ts index fe0c2ad196..8ea6a0e422 100644 --- a/packages/components/tree/tree-option.component.ts +++ b/packages/components/tree/tree-option.component.ts @@ -651,8 +651,9 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte markForCheck() { this.changeDetectorRef.markForCheck(); - // Its own state only: after a selection, data or mode change the tree calls this on every option. - if (this.showCheckbox) { + // Its own state only: after a selection, data or mode change the tree calls this on every option. A projected + // checkbox shows the state while the tree's own is hidden. + if (this.showCheckbox || this.externalPseudoCheckbox) { this.syncCheckboxState(); } } @@ -672,8 +673,8 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte if (this.selectAllRow()) return; if (this.checkboxThirdState() && this.isExpandable) { - // One descendant walk, not two: `getDescendants` is a linear scan of the whole data set, and - // the roll-up runs this method for every ancestor. + // One descendant walk, not two: each walk covers the whole subtree, and the roll-up runs this method for + // every ancestor. const descendants = this.tree.treeControl.getDescendants(this.data); const selectedCount = descendants.filter((child) => this.tree.selectionModel.isSelected(child)).length; diff --git a/packages/components/tree/tree-selection.component.spec.ts b/packages/components/tree/tree-selection.component.spec.ts index 88dd70d3d5..61e7adc556 100644 --- a/packages/components/tree/tree-selection.component.spec.ts +++ b/packages/components/tree/tree-selection.component.spec.ts @@ -35,6 +35,7 @@ import { KbqMultipleInput, KbqOptionActionComponent, KbqOptionModule, + KbqPseudoCheckbox, KbqStateSavingService, KbqStateStore, LEFT_ARROW, @@ -2288,6 +2289,35 @@ describe('KbqTreeSelection', () => { }); }); + describe('tri-state checkbox projected while the tree hides its own', () => { + it('should keep the projected checkbox of an ancestor in step with the selection', async () => { + vi.useFakeTimers(); + configureKbqTreeTestingModule(); + + const fixture = TestBed.createComponent(TreeWithProjectedThirdStateCheckbox); + const { tree, treeControl } = fixture.componentInstance; + const settle = async () => { + fixture.detectChanges(); + await vi.advanceTimersByTimeAsync(0); + fixture.detectChanges(); + }; + + await settle(); + treeControl.expand(treeControl.hasValue('Pictures')!); + await settle(); + + tree().selectionModel.select(treeControl.hasValue('Sun')!); + await settle(); + + const pictures = getNodeByText(fixture.nativeElement, 'Pictures'); + + expect(tree().renderedOptions.find((option) => option.value === 'Pictures')!.checkboxState).toBe( + 'indeterminate' + ); + expect(pictures.querySelector('kbq-pseudo-checkbox')!.classList).toContain('kbq-indeterminate'); + }); + }); + describe('tri-state checkbox over replaced data', () => { let fixture: ComponentFixture; let component: TreeWithTrackedThirdStateCheckbox; @@ -2390,26 +2420,12 @@ describe('KbqTreeSelection', () => { expect(root.checkboxState).toBe('indeterminate'); }); - it('should not roll every newly rendered row up through its ancestors', async () => { - vi.useFakeTimers(); - - const rollUps = vi.spyOn(component.treeControl, 'getParents'); - - component.treeControl.expandAll(); - await settle(); - - expect(getNodes(treeElement).length).toBe(component.treeControl.dataNodes.length); - expect(rollUps).not.toHaveBeenCalled(); - }); - - it('should not roll the selected rows a render brings in up through their ancestors', async () => { + it('should roll no rendered row up through its ancestors, selected ones included', async () => { vi.useFakeTimers(); const { tree, treeControl } = component; - tree().selectionModel.select( - treeControl.dataNodes.find((node) => node.name === `node-${DEEP_CHAIN_DEPTH}`)! - ); + tree().selectionModel.select(treeControl.hasValue(`node-${DEEP_CHAIN_DEPTH}`)!); await settle(); const rollUps = vi.spyOn(treeControl, 'getParents'); @@ -2417,26 +2433,12 @@ describe('KbqTreeSelection', () => { treeControl.expandAll(); await settle(); + expect(getNodes(treeElement).length).toBe(treeControl.dataNodes.length); expect(rollUps).not.toHaveBeenCalled(); expect(optionFor(`node-${DEEP_CHAIN_DEPTH}`).selected).toBe(true); expect(optionFor('node-0').checkboxState).toBe('indeterminate'); }); - // A search of the data nodes for each branch a sweep recomputes would make it cost rows × nodes. - it('should not search the data nodes for the rows it sweeps', async () => { - vi.useFakeTimers(); - - component.treeControl.expandAll(); - await settle(); - - const indexOf = vi.spyOn(component.treeControl.dataNodes, 'indexOf'); - - dispatchEvent(getNodeByText(treeElement, `node-${DEEP_CHAIN_DEPTH}`), createMouseEvent('click')); - await settle(); - - expect(indexOf).not.toHaveBeenCalled(); - }); - it('should sweep the rows once for the selection changes of one task', async () => { vi.useFakeTimers(); @@ -2448,7 +2450,7 @@ describe('KbqTreeSelection', () => { const sweeps = vi.spyOn(optionFor('node-0'), 'markForCheck'); ['leaf-1', 'leaf-2', 'leaf-3'].forEach((name) => - tree().selectionModel.select(treeControl.dataNodes.find((node) => node.name === name)!) + tree().selectionModel.select(treeControl.hasValue(name)!) ); await settle(); @@ -3978,6 +3980,37 @@ class TreeWithThirdStateCheckbox extends TreeParams { readonly tree = viewChild.required(KbqTreeSelection); } +@Component({ + imports: [KbqTreeModule, KbqPseudoCheckbox], + template: ` + + + + {{ node.name }} + + + + + + {{ node.name }} + + + ` +}) +class TreeWithProjectedThirdStateCheckbox extends TreeParams { + readonly tree = viewChild.required(KbqTreeSelection); +} + const DEEP_CHAIN_DEPTH = 12; /** @@ -5752,28 +5785,6 @@ describe('KbqTreeSelection rendering a change of the data', () => { expect(checks.rows).toEqual([1, 1, 1, 1, 1]); }); - // A search of the selected values per row would make a render cost rows × selected. - it('should read the selected values once for a render of the rows, not once per row', async () => { - const { tree, treeControl } = fixture.componentInstance; - const selectedValues = tree().getSelectedValues(); - let reads = 0; - const countedValues = new Proxy(selectedValues, { - get: (target, key, receiver) => { - if (typeof key === 'string' && /^\d+$/.test(key)) reads++; - - return Reflect.get(target, key, receiver); - } - }); - - vi.spyOn(tree(), 'getSelectedValues').mockReturnValue(countedValues); - - treeControl.expand(node('Documents')); - await settle(); - - expect(getNodes(fixture.nativeElement).length).toBeGreaterThan(5); - expect(reads).toBeLessThanOrEqual(selectedValues.length); - }); - it('should hand the rows of data that arrives outside change detection to the keyboard', async () => { fixture.componentInstance.selectAll = false; await settle(); @@ -5887,50 +5898,77 @@ class TreeWithSharedValues extends TreeParams { super(); // Rows share values (`jpg`, `ts`, `pdf`, `app`): only the node tells such rows apart. - this.treeControl = new FlatTreeControl( - this.getLevel, - this.isExpandable, - (node: FileFlatNode) => node.type ?? node.name, - this.getValue - ); - this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener); - this.dataSource.data = this.treeData; + this.treeControl.getValue = (node: FileFlatNode) => node.type ?? node.name; } } -describe('KbqTreeSelection keyboard order', () => { +describe('KbqTreeSelection with rows that share a value', () => { let fixture: ComponentFixture; - const node = (name: string): FileFlatNode => - fixture.componentInstance.treeControl.dataNodes.find((dataNode) => dataNode.name === name)!; + const settle = async (): Promise => { + fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + }; + + const folder = (name: string): FileFlatNode => fixture.componentInstance.treeControl.hasValue(name)!; /** The rows the keyboard moves through, in its order. */ const keyboardRows = (): HTMLElement[] => fixture.componentInstance.tree().renderedOptions.map((option) => option.getHostElement()); + const selectedRows = (): string[] => + getNodes(fixture.nativeElement) + .filter((row) => row.classList.contains('kbq-selected')) + .map((row) => row.textContent!.trim()); + beforeEach(() => { fixture = TestBed.createComponent(TreeWithSharedValues); fixture.detectChanges(); }); + it('should mark only the selected node among the rows that share its value', async () => { + const { tree, treeControl } = fixture.componentInstance; + + treeControl.expand(folder('Pictures')); + treeControl.expand(folder('Documents')); + await settle(); + + dispatchFakeEvent(getNodeByText(fixture.nativeElement, 'Woods'), 'click'); + await settle(); + + expect(tree().selectionModel.selected.map(({ name }) => name)).toEqual(['Woods']); + expect(selectedRows()).toEqual(['Woods']); + }); + + it('should mark no row while nothing is selected, rows without a value included', async () => { + fixture = TestBed.createComponent(TreeWithSharedValues); + // Folders carry no value. + fixture.componentInstance.treeControl.getValue = (node: FileFlatNode) => node.type; + await settle(); + + expect(getNodes(fixture.nativeElement).length).toBeGreaterThan(0); + expect(selectedRows()).toEqual([]); + }); + it('should follow the rows on screen across expanding, collapsing and new data, with values shared by rows', () => { const { dataSource, treeControl } = fixture.componentInstance; - treeControl.expand(node('Pictures')); - treeControl.expand(node('Documents')); + treeControl.expand(folder('Pictures')); + treeControl.expand(folder('Documents')); TestBed.tick(); expect(getNodes(fixture.nativeElement).length).toBe(11); expect(keyboardRows()).toEqual(getNodes(fixture.nativeElement)); - treeControl.collapse(node('Pictures')); + treeControl.collapse(folder('Pictures')); TestBed.tick(); expect(getNodes(fixture.nativeElement).length).toBe(8); expect(keyboardRows()).toEqual(getNodes(fixture.nativeElement)); dataSource.data = buildFileTree(DATA_OBJECT, 0); - treeControl.expand(node('Downloads')); + treeControl.expand(folder('Downloads')); TestBed.tick(); expect(getNodes(fixture.nativeElement).length).toBe(8); diff --git a/packages/components/tree/tree-selection.component.ts b/packages/components/tree/tree-selection.component.ts index e332194f4c..56ee265fc7 100644 --- a/packages/components/tree/tree-selection.component.ts +++ b/packages/components/tree/tree-selection.component.ts @@ -870,30 +870,28 @@ export class KbqTreeSelection this.listenToOptionsFocus(); - // A render and the selection changes of one task each notify: the first sweep reads them all, and the others are - // dropped. Each notification keeps its own timer, where `auditTime` would tie the later ones to the first one's. - let requestedSweeps = 0; - let sweptRequests = 0; + // A render and the selection changes of one task each notify, each with its own timer: the first to fire sweeps + // for all of them and the rest are dropped. `auditTime` would tie the later ones to the first one's timer. + let sweeps = 0; this.renderedOptions.changes .pipe( - map((options) => ({ options, request: ++requestedSweeps })), + map(() => sweeps), delay(0, this.scheduler), - filter(({ request }) => request > sweptRequests), + filter((sweepsWhenNotified) => sweepsWhenNotified === sweeps), takeUntilDestroyed(this.destroyRef) ) - .subscribe(({ options }) => { - sweptRequests = requestedSweeps; + .subscribe(() => { + sweeps++; // Check to see if we need to update our tab index this.updateTabIndex(); this.syncSelectionModelToDataNodes(); - const selectedValues = new Set(this.multiple ? this.getSelectedValues() : [this.getSelectedValues()]); - - options.forEach((option) => { - if (selectedValues.has(option.value)) { + this.renderedOptions.forEach((option) => { + // By node rather than by value: rows may share a value. + if (this.selectionModel.isSelected(option.data)) { option.select(false); } else { option.deselect();