Skip to content

Commit

Permalink
refactor(cdk/tree): simplify code in lifecycle hooks (#28124)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
zarend authored Nov 15, 2023
1 parent c4aa307 commit 372f9df
Showing 1 changed file with 135 additions and 100 deletions.
235 changes: 135 additions & 100 deletions src/cdk/tree/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ function coerceObservable<T>(data: T | Observable<T>): Observable<T> {
return data;
}

type RenderingData<T> =
| {
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.
Expand Down Expand Up @@ -256,29 +268,13 @@ export class CdkTree<T, K = T>
private _elementRef: ElementRef<HTMLElement>,
) {}

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() {
Expand All @@ -298,51 +294,17 @@ export class CdkTree<T, K = T>
}
}

ngAfterContentInit() {
const items = combineLatest([this._keyManagerNodes, this._nodes]).pipe(
map(([keyManagerNodes, renderNodes]) =>
keyManagerNodes.reduce<CdkTreeNode<T, K>[]>((items, data) => {
const node = renderNodes.get(this._getExpansionKey(data));
if (node) {
items.push(node);
}
return items;
}, []),
),
);

const keyManagerOptions: TreeKeyManagerOptions<CdkTreeNode<T, K>> = {
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();
}
}

/**
Expand Down Expand Up @@ -396,12 +358,16 @@ export class CdkTree<T, K = T>

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<readonly T[]> | undefined;

if (isDataSource(this._dataSource)) {
Expand All @@ -412,6 +378,13 @@ export class CdkTree<T, K = T>
dataStream = observableOf(this._dataSource);
}

if (!dataStream) {
if (typeof ngDevMode === 'undefined' || ngDevMode) {
throw getTreeNoValidDataSourceError();
}
return;
}

let expansionModel;
if (!this.treeControl) {
this._expansionModel = new SelectionModel<K>(true);
Expand All @@ -420,49 +393,48 @@ export class CdkTree<T, K = T>
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<T>);
});
}

private _renderDataChanges(data: RenderingData<T>) {
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<K> | null) {
Expand All @@ -481,6 +453,69 @@ export class CdkTree<T, K = T>
}
}

private _initializeKeyManager() {
const items = combineLatest([this._keyManagerNodes, this._nodes]).pipe(
map(([keyManagerNodes, renderNodes]) =>
keyManagerNodes.reduce<CdkTreeNode<T, K>[]>((items, data) => {
const node = renderNodes.get(this._getExpansionKey(data));
if (node) {
items.push(node);
}
return items;
}, []),
),
);

const keyManagerOptions: TreeKeyManagerOptions<CdkTreeNode<T, K>> = {
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[],
Expand Down

0 comments on commit 372f9df

Please sign in to comment.