Repository: microsoft/vscode (TypeScript)
Date: 2022-07-20 12:38:55+00:00
Task type: Bug Fix

Issue:
TreeItem `getChildren` shouldn't be called unless the item is expanded
If you "refresh" an item (with children) by calling `this._onDidChangeTreeData.fire(<item>)` and the item is not expanded, the `getChildren` method still gets called. `getChildren` should not be called until the item is expanded, otherwise potentially expensive operations can be run for no visible reason (since the children are hidden).


Issue discussion:
This could result in unexpected behavior. If an extension currently relies on `this._onDidChangeTreeData.fire(<item>)` to always update the children then there would be out-of-date tree items. 
@alexr00 How would this result in unexpected behavior? If the tree item has not loaded its children, why should calling refresh on it force its children to load?
I'm concerned that some extension authors have come to depend on the current behavior to keep their internal tree structure updated. I suppose we can try it and see if there are any complaints.
@joaomoreno I think a change like this would need to happen in the `AsyncDataTree`, as at the custom tree view level we have no knowledge of whether a node is currently expanded or not. Is this something you'd be interested in doing?
@alexr00 Not sure I follow. What @eamodio describes is exactly how the AsyncDataTree works: children aren't resolved until the user expands the parent the first time.
@joaomoreno I am looking for a way to call `updateChildren` on the AsyncDataTree such that if the tree item passed in to `updateChildren` is collapsed, it will:
- "invalidate" the children or the tree item and not actually call `getChildren` on that tree item
- rerender the tree item

instead of:
- calling `getChildren` for the tree item
- rerendering the tree item
Oh I guess I was wrong, what I said only applies to the next level. But the first node `updateChilren` is called with will always have its `getChildren` be called.

We can maybe add an option here, or even change the default behavior as I see no reason for collapsed tree nodes to need a `getChildren` call.
@joaomoreno @alexr00 any update here? This issue causes GitLens to request way more data than desired in multiple cases (before the user expands them that is).

Solution patch:
diff --git a/src/vs/base/browser/ui/tree/asyncDataTree.ts b/src/vs/base/browser/ui/tree/asyncDataTree.ts
--- a/src/vs/base/browser/ui/tree/asyncDataTree.ts
+++ b/src/vs/base/browser/ui/tree/asyncDataTree.ts
@@ -742,6 +742,16 @@ export class AsyncDataTree<TInput, T, TFilterData = void> implements IDisposable
 			return result;
 		}
 
+		if (node !== this.root) {
+			const treeNode = this.tree.getNode(node);
+
+			if (treeNode.collapsed) {
+				node.hasChildren = !!this.dataSource.hasChildren(node.element!);
+				node.stale = true;
+				return;
+			}
+		}
+
 		return this.doRefreshSubTree(node, recursive, viewStateContext);
 	}
 


Test patch:
diff --git a/src/vs/base/test/browser/ui/tree/asyncDataTree.test.ts b/src/vs/base/test/browser/ui/tree/asyncDataTree.test.ts
--- a/src/vs/base/test/browser/ui/tree/asyncDataTree.test.ts
+++ b/src/vs/base/test/browser/ui/tree/asyncDataTree.test.ts
@@ -8,6 +8,7 @@ import { IIdentityProvider, IListVirtualDelegate } from 'vs/base/browser/ui/list
 import { AsyncDataTree } from 'vs/base/browser/ui/tree/asyncDataTree';
 import { IAsyncDataSource, ITreeNode, ITreeRenderer } from 'vs/base/browser/ui/tree/tree';
 import { timeout } from 'vs/base/common/async';
+import { Iterable } from 'vs/base/common/iterator';
 
 interface Element {
 	id: string;
@@ -435,4 +436,60 @@ suite('AsyncDataTree', function () {
 
 		assert.deepStrictEqual(Array.from(container.querySelectorAll('.monaco-list-row')).map(e => e.textContent), ['a', 'b2']);
 	});
+
+	test('issue #121567', async () => {
+		const container = document.createElement('div');
+
+		const calls: Element[] = [];
+		const dataSource = new class implements IAsyncDataSource<Element, Element> {
+			hasChildren(element: Element): boolean {
+				return !!element.children && element.children.length > 0;
+			}
+			async getChildren(element: Element) {
+				calls.push(element);
+				return element.children ?? Iterable.empty();
+			}
+		};
+
+		const model = new Model({
+			id: 'root',
+			children: [{
+				id: 'a', children: [{
+					id: 'aa'
+				}]
+			}]
+		});
+		const a = model.get('a');
+
+		const tree = new AsyncDataTree<Element, Element>('test', container, new VirtualDelegate(), [new Renderer()], dataSource, { identityProvider: new IdentityProvider() });
+		tree.layout(200);
+
+		await tree.setInput(model.root);
+		assert.strictEqual(calls.length, 1, 'There should be a single getChildren call for the root');
+		assert(tree.isCollapsible(a), 'a is collapsible');
+		assert(tree.isCollapsed(a), 'a is collapsed');
+
+		await tree.updateChildren(a, false);
+		assert.strictEqual(calls.length, 1, 'There should be no changes to the calls list, since a was collapsed');
+		assert(tree.isCollapsible(a), 'a is collapsible');
+		assert(tree.isCollapsed(a), 'a is collapsed');
+
+		const children = a.children;
+		a.children = [];
+		await tree.updateChildren(a, false);
+		assert.strictEqual(calls.length, 1, 'There should still be no changes to the calls list, since a was collapsed');
+		assert(!tree.isCollapsible(a), 'a is no longer collapsible');
+		assert(tree.isCollapsed(a), 'a is collapsed');
+
+		a.children = children;
+		await tree.updateChildren(a, false);
+		assert.strictEqual(calls.length, 1, 'There should still be no changes to the calls list, since a was collapsed');
+		assert(tree.isCollapsible(a), 'a is collapsible again');
+		assert(tree.isCollapsed(a), 'a is collapsed');
+
+		await tree.expand(a);
+		assert.strictEqual(calls.length, 2, 'Finally, there should be a getChildren call for a');
+		assert(tree.isCollapsible(a), 'a is still collapsible');
+		assert(!tree.isCollapsed(a), 'a is expanded');
+	});
 });