From 372f9dfa2aceba165d1b55e18423e4e740ae063e Mon Sep 17 00:00:00 2001 From: Zach Arend Date: Tue, 14 Nov 2023 16:01:07 -0800 Subject: [PATCH] refactor(cdk/tree): simplify code in lifecycle hooks (#28124) refactor(cdk/tree): simplify code in lifecycle hooks Refactor CdkTree implementation to simplify code in lifecycle hooks. Create a RenderingData type definition to simplify the Tree's rendering pipeline. The render pipeline looks like this. It has two entry points ngAfterContentChecked and whener the datasource is switched. 1. ngAfterContentChecked OR datasource changed 2. call _subscribeToDataChanges From here, the pipeline diverges. Tree subscribes to changes in the datasource and expansion state. From there, the pipeline goes on two paths: emit changes to expansion state, and render the changes from the datasource. Add RenderingData type to make it more clear what stages are in the pipeline. This commit message will be squashed away. --- src/cdk/tree/tree.ts | 235 +++++++++++++++++++++++++------------------ 1 file changed, 135 insertions(+), 100 deletions(-) diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 5734a89cc682..770421e924d7 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -86,6 +86,18 @@ function coerceObservable(data: T | Observable): Observable { return data; } +type RenderingData = + | { + flattenedNodes: null; + nodeType: null; + renderNodes: T[]; + } + | { + flattenedNodes: T[]; + nodeType: 'nested' | 'flat'; + renderNodes: []; + }; + /** * CDK tree component that connects with a data source to retrieve data of type `T` and renders * dataNodes with hierarchy. Updates the dataNodes when new data is provided by the data source. @@ -256,29 +268,13 @@ export class CdkTree private _elementRef: ElementRef, ) {} - ngOnInit() { - this._dataDiffer = this._differs.find([]).create(this.trackBy); - if (typeof ngDevMode === 'undefined' || ngDevMode) { - // Verify that Tree follows API contract of using one of TreeControl, levelAccessor or - // childrenAccessor. Throw an appropriate error if contract is not met. - let numTreeControls = 0; - - if (this.treeControl) { - numTreeControls++; - } - if (this.levelAccessor) { - numTreeControls++; - } - if (this.childrenAccessor) { - numTreeControls++; - } + ngAfterContentInit() { + this._initializeKeyManager(); + } - if (!numTreeControls) { - throw getTreeControlMissingError(); - } else if (numTreeControls > 1) { - throw getMultipleTreeControlsError(); - } - } + ngAfterContentChecked() { + this._updateDefaultNodeDefinition(); + this._subscribeToDataChanges(); } ngOnDestroy() { @@ -298,51 +294,17 @@ export class CdkTree } } - ngAfterContentInit() { - const items = combineLatest([this._keyManagerNodes, this._nodes]).pipe( - map(([keyManagerNodes, renderNodes]) => - keyManagerNodes.reduce[]>((items, data) => { - const node = renderNodes.get(this._getExpansionKey(data)); - if (node) { - items.push(node); - } - return items; - }, []), - ), - ); - - const keyManagerOptions: TreeKeyManagerOptions> = { - trackBy: node => this._getExpansionKey(node.data), - skipPredicate: node => !!node.isDisabled, - typeAheadDebounceInterval: true, - horizontalOrientation: this._dir.value, - }; - - this._keyManager = this._keyManagerFactory(items, keyManagerOptions); - - this._keyManager.change - .pipe(startWith(null), pairwise(), takeUntil(this._onDestroy)) - .subscribe(([prev, next]) => { - prev?._setTabUnfocusable(); - next?._setTabFocusable(); - }); - - this._keyManager.change.pipe(startWith(null), takeUntil(this._onDestroy)).subscribe(() => { - // refresh the tabindex when the active item changes. - this._setTabIndex(); - }); + ngOnInit() { + this._checkTreeControlUsage(); + this._initializeDataDiffer(); } - ngAfterContentChecked() { + private _updateDefaultNodeDefinition() { const defaultNodeDefs = this._nodeDefs.filter(def => !def.when); if (defaultNodeDefs.length > 1 && (typeof ngDevMode === 'undefined' || ngDevMode)) { throw getTreeMultipleDefaultNodeDefsError(); } this._defaultNodeDef = defaultNodeDefs[0]; - - if (this.dataSource && this._nodeDefs && !this._dataSubscription) { - this._observeRenderChanges(); - } } /** @@ -396,12 +358,16 @@ export class CdkTree this._dataSource = dataSource; if (this._nodeDefs) { - this._observeRenderChanges(); + this._subscribeToDataChanges(); } } /** Set up a subscription for the data provided by the data source. */ - private _observeRenderChanges() { + private _subscribeToDataChanges() { + if (this._dataSubscription) { + return; + } + let dataStream: Observable | undefined; if (isDataSource(this._dataSource)) { @@ -412,6 +378,13 @@ export class CdkTree dataStream = observableOf(this._dataSource); } + if (!dataStream) { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + throw getTreeNoValidDataSourceError(); + } + return; + } + let expansionModel; if (!this.treeControl) { this._expansionModel = new SelectionModel(true); @@ -420,49 +393,48 @@ export class CdkTree expansionModel = this.treeControl.expansionModel; } - if (dataStream) { - this._dataSubscription = combineLatest([ - dataStream, - this._nodeType, - // NB: the data is unused below, however we add it here to essentially - // trigger data rendering when expansion changes occur. - expansionModel.changed.pipe( - startWith(null), - tap(expansionChanges => { - this._emitExpansionChanges(expansionChanges); - }), - ), - ]) - .pipe( - switchMap(([data, nodeType]) => { - if (nodeType === null) { - return observableOf([{renderNodes: data}, nodeType] as const); - } - - // If we're here, then we know what our node type is, and therefore can - // perform our usual rendering pipeline, which necessitates converting the data - return this._convertData(data, nodeType).pipe( - map(convertedData => [convertedData, nodeType] as const), - ); - }), - takeUntil(this._onDestroy), - ) - .subscribe(([data, nodeType]) => { + this._dataSubscription = combineLatest([ + dataStream, + this._nodeType, + // NB: the data is unused below, however we add it here to essentially + // trigger data rendering when expansion changes occur. + expansionModel.changed.pipe( + startWith(null), + tap(expansionChanges => { + this._emitExpansionChanges(expansionChanges); + }), + ), + ]) + .pipe( + switchMap(([data, nodeType]) => { if (nodeType === null) { - // Skip saving cached and key manager data. - this.renderNodeChanges(data.renderNodes); - return; + return observableOf([{renderNodes: data}, nodeType] as const); } // If we're here, then we know what our node type is, and therefore can - // perform our usual rendering pipeline. - this._updateCachedData(data.flattenedNodes); - this.renderNodeChanges(data.renderNodes); - this._updateKeyManagerItems(data.flattenedNodes); - }); - } else if (typeof ngDevMode === 'undefined' || ngDevMode) { - throw getTreeNoValidDataSourceError(); + // perform our usual rendering pipeline, which necessitates converting the data + return this._convertData(data, nodeType).pipe( + map(convertedData => [convertedData, nodeType] as const), + ); + }), + takeUntil(this._onDestroy), + ) + .subscribe(([data, nodeType]) => { + this._renderDataChanges({nodeType, ...data} as RenderingData); + }); + } + + private _renderDataChanges(data: RenderingData) { + if (data.nodeType === null) { + this.renderNodeChanges(data.renderNodes); + return; } + + // If we're here, then we know what our node type is, and therefore can + // perform our usual rendering pipeline. + this._updateCachedData(data.flattenedNodes); + this.renderNodeChanges(data.renderNodes); + this._updateKeyManagerItems(data.flattenedNodes); } private _emitExpansionChanges(expansionChanges: SelectionChange | null) { @@ -481,6 +453,69 @@ export class CdkTree } } + private _initializeKeyManager() { + const items = combineLatest([this._keyManagerNodes, this._nodes]).pipe( + map(([keyManagerNodes, renderNodes]) => + keyManagerNodes.reduce[]>((items, data) => { + const node = renderNodes.get(this._getExpansionKey(data)); + if (node) { + items.push(node); + } + return items; + }, []), + ), + ); + + const keyManagerOptions: TreeKeyManagerOptions> = { + trackBy: node => this._getExpansionKey(node.data), + skipPredicate: node => !!node.isDisabled, + typeAheadDebounceInterval: true, + horizontalOrientation: this._dir.value, + }; + + this._keyManager = this._keyManagerFactory(items, keyManagerOptions); + + this._keyManager.change + .pipe(startWith(null), pairwise(), takeUntil(this._onDestroy)) + .subscribe(([prev, next]) => { + prev?._setTabUnfocusable(); + next?._setTabFocusable(); + }); + + this._keyManager.change.pipe(startWith(null), takeUntil(this._onDestroy)).subscribe(() => { + // refresh the tabindex when the active item changes. + this._setTabIndex(); + }); + } + + private _initializeDataDiffer() { + this._dataDiffer = this._differs.find([]).create(this.trackBy); + } + + private _checkTreeControlUsage() { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + // Verify that Tree follows API contract of using one of TreeControl, levelAccessor or + // childrenAccessor. Throw an appropriate error if contract is not met. + let numTreeControls = 0; + + if (this.treeControl) { + numTreeControls++; + } + if (this.levelAccessor) { + numTreeControls++; + } + if (this.childrenAccessor) { + numTreeControls++; + } + + if (!numTreeControls) { + throw getTreeControlMissingError(); + } else if (numTreeControls > 1) { + throw getMultipleTreeControlsError(); + } + } + } + /** Check for changes made in the data and render each change (node added/removed/moved). */ renderNodeChanges( data: readonly T[],