Repository: microsoft/vscode (TypeScript)
Date: 2022-06-21 18:25:22+00:00
Task type: Bug Fix

Issue:
Flicker on the ... menu next to a cell output
Testing #124995 on Windows

Here's what's happening in the following gif:
1. Click on another cell (not the one who's ... I'm about to click.
2. Click on the ... of the output cell.
3. The menu shows for a split second then goes away.
4. Click on the same ...
5. The menu now shows properly.

![recording (56)](https://user-images.githubusercontent.com/38270282/120338947-f8fbea80-c2f4-11eb-9f62-1afc756ac2cd.gif)



Issue discussion:
The mouse click on the `...` should not change the editor focus, like the run button.
I think we have a workaround for this on the cell toolbar, but I didn't think it would be necessary here because none of those actions depend on focus.

Also maybe it should respect `notebook.cellToolbarVisibility`
Still repros for me. It actually repros each time I click the ... now, instead of only the first time.
Only on Windows it seems...
This is like #103926 but it seems that issue can have an impact outside of just focus-related context keys. Basically, a context key (cell type, "cell has outputs", etc) changes in the parent CKS, but is overridden in the scoped CKS. So the effective value in the scoped CKS did not change, but the menu still tries to refresh itself and closes.

We have a hacky workaround for the original focus issue for the cell title toolbar

https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/notebook/browser/view/renderers/cellRenderer.ts#L212-L242

But actually this can impact any toolbar.

I can copy the workaround for the output toolbar for now. Or we can disable the consolidated output toolbar by default.

I still have no clue why it works on Linux but not Windows, since they should be using the same custom toolbar. 
In the sync today, the vote was to leave this alone since it isn't totally blocking and wasn't noticed in the past 2 or 3 weeks. I think we should either leave it for next month or disable `notebook.consolidatedOutputButton`.
The issue is described in https://github.com/microsoft/vscode/issues/125109#issuecomment-854945097 and is really about menus in general. It's really annoying and there are some things I can do to fix it. Any one of these would be fine.

- Child CKS fires an onDidChange event when a value changes in its parent, even when that key is overridden by the child
  - We could check for this and not fire the event in this case at https://github.com/microsoft/vscode/blob/2297ad5c546b455421e942743e117a3c7c9a873a/src/vs/platform/contextkey/browser/contextKeyService.ts#L442. Would probably reduce a lot of noise/unnecessary updating in the workbench. Would take a little refactoring - the `onDidChangeContext` doesn't contain enough info to do this. We can expand the event to contain a full list of types that are only exposed internally, not to `IContextKeyService` consumers.
- The Menu fires its onDidChange because some context keys were updated, but it doesn't check whether the actual list of menu items changed
  - Maybe it could diff its list of menu items to avoid this. But the Menu doesn't cache a list of menu items, so that isn't currently possible.
- Calling setActions on the toolbar with an identical list of actions still causes it to hide
  - We could diff the actions to check that here, but that seems too late for that sort of check


After writing all this out, I think the first makes sense, and I could take a crack at a PR, lmk what you think @jrieken  @sbatten 
> Would probably reduce a lot of noise/unnecessary updating in the workbench. Would take a little refactoring - the onDidChangeContext doesn't contain enough info to do this.

I like that. Also, I do believe the `onDidChangeContext` knows enough, the child CKS service would need to know its keys and ask the event if any of those are affected. Otherwise the CKS should have access to all possible implementations of `IContextKeyChangeEvent`

> The Menu fires its onDidChange because some context keys were updated, but it doesn't check whether the actual list of menu items changed

Careful - context keys for menus control not just inclusion into the menu but also enablement and toggle-states. See how `_fillInKbExprKeys` is used [here](https://github.com/microsoft/vscode/blob/dafc40bca5439520d7e8b1c4a5c15c744bf69c10/src/vs/platform/actions/common/menuService.ts#L126) 
> the child CKS service would need to know its keys and ask the event if any of those are affected

I'm thinking the check has to be, whether _all_ of the keys in the event are contained within the child CKS, not _some_. The `affectsSome` method isn't enough for that but I think you are pointing out that I can just check all the implementations, and for example I can see the full list of keys on `ArrayContextKeyChangeEvent`.

Solution patch:
diff --git a/src/vs/platform/contextkey/browser/contextKeyService.ts b/src/vs/platform/contextkey/browser/contextKeyService.ts
--- a/src/vs/platform/contextkey/browser/contextKeyService.ts
+++ b/src/vs/platform/contextkey/browser/contextKeyService.ts
@@ -22,7 +22,7 @@ const KEYBINDING_CONTEXT_ATTR = 'data-keybinding-context';
 export class Context implements IContext {
 
 	protected _parent: Context | null;
-	protected _value: { [key: string]: any };
+	protected _value: Record<string, any>;
 	protected _id: number;
 
 	constructor(id: number, parent: Context | null) {
@@ -32,6 +32,10 @@ export class Context implements IContext {
 		this._value['_contextId'] = id;
 	}
 
+	public get value(): Record<string, any> {
+		return { ...this._value };
+	}
+
 	public setValue(key: string, value: any): boolean {
 		// console.log('SET ' + key + ' = ' + value + ' ON ' + this._id);
 		if (this._value[key] !== value) {
@@ -62,7 +66,7 @@ export class Context implements IContext {
 		this._parent = parent;
 	}
 
-	public collectAllValues(): { [key: string]: any } {
+	public collectAllValues(): Record<string, any> {
 		let result = this._parent ? this._parent.collectAllValues() : Object.create(null);
 		result = { ...result, ...this._value };
 		delete result['_contextId'];
@@ -247,6 +251,12 @@ class CompositeContextKeyChangeEvent implements IContextKeyChangeEvent {
 	}
 }
 
+function allEventKeysInContext(event: IContextKeyChangeEvent, context: Record<string, any>): boolean {
+	return (event instanceof ArrayContextKeyChangeEvent && event.keys.every(key => key in context)) ||
+		(event instanceof SimpleContextKeyChangeEvent && event.key in context) ||
+		(event instanceof CompositeContextKeyChangeEvent && event.events.every(e => allEventKeysInContext(e, context)));
+}
+
 export abstract class AbstractContextKeyService implements IContextKeyService {
 	declare _serviceBrand: undefined;
 
@@ -439,7 +449,14 @@ class ScopedContextKeyService extends AbstractContextKeyService {
 
 	private _updateParentChangeListener(): void {
 		// Forward parent events to this listener. Parent will change.
-		this._parentChangeListener.value = this._parent.onDidChangeContext(this._onDidChangeContext.fire, this._onDidChangeContext);
+		this._parentChangeListener.value = this._parent.onDidChangeContext(e => {
+			const thisContainer = this._parent.getContextValuesContainer(this._myContextId);
+			const thisContextValues = thisContainer.value;
+
+			if (!allEventKeysInContext(e, thisContextValues)) {
+				this._onDidChangeContext.fire(e);
+			}
+		});
 	}
 
 	public dispose(): void {


Test patch:
diff --git a/src/vs/platform/contextkey/test/browser/contextkey.test.ts b/src/vs/platform/contextkey/test/browser/contextkey.test.ts
--- a/src/vs/platform/contextkey/test/browser/contextkey.test.ts
+++ b/src/vs/platform/contextkey/test/browser/contextkey.test.ts
@@ -3,6 +3,7 @@
  *  Licensed under the MIT License. See License.txt in the project root for license information.
  *--------------------------------------------------------------------------------------------*/
 import * as assert from 'assert';
+import { DeferredPromise } from 'vs/base/common/async';
 import { DisposableStore } from 'vs/base/common/lifecycle';
 import { URI } from 'vs/base/common/uri';
 import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
@@ -73,4 +74,77 @@ suite('ContextKeyService', () => {
 		const expr = ContextKeyExpr.in('notebookCellResource', 'jupyter.runByLineCells');
 		assert.deepStrictEqual(contextKeyService.contextMatchesRules(expr), true);
 	});
+
+	test('suppress update event from parent when one key is overridden by child', () => {
+		const root = new ContextKeyService(new TestConfigurationService());
+		const child = root.createScoped(document.createElement('div'));
+
+		root.createKey('testA', 1);
+		child.createKey('testA', 4);
+
+		let fired = false;
+		const event = child.onDidChangeContext(e => fired = true);
+		root.setContext('testA', 10);
+		assert.strictEqual(fired, false, 'Should not fire event when overridden key is updated in parent');
+		event.dispose();
+	});
+
+	test('suppress update event from parent when all keys are overridden by child', () => {
+		const root = new ContextKeyService(new TestConfigurationService());
+		const child = root.createScoped(document.createElement('div'));
+
+		root.createKey('testA', 1);
+		root.createKey('testB', 2);
+		root.createKey('testC', 3);
+
+		child.createKey('testA', 4);
+		child.createKey('testB', 5);
+		child.createKey('testD', 6);
+
+		let fired = false;
+		const event = child.onDidChangeContext(e => fired = true);
+		root.bufferChangeEvents(() => {
+			root.setContext('testA', 10);
+			root.setContext('testB', 20);
+			root.setContext('testD', 30);
+		});
+
+		assert.strictEqual(fired, false, 'Should not fire event when overridden key is updated in parent');
+		event.dispose();
+	});
+
+	test('pass through update event from parent when one key is not overridden by child', () => {
+		const root = new ContextKeyService(new TestConfigurationService());
+		const child = root.createScoped(document.createElement('div'));
+
+		root.createKey('testA', 1);
+		root.createKey('testB', 2);
+		root.createKey('testC', 3);
+
+		child.createKey('testA', 4);
+		child.createKey('testB', 5);
+		child.createKey('testD', 6);
+
+		const def = new DeferredPromise();
+		child.onDidChangeContext(e => {
+			try {
+				assert.ok(e.affectsSome(new Set(['testA'])), 'testA changed');
+				assert.ok(e.affectsSome(new Set(['testB'])), 'testB changed');
+				assert.ok(e.affectsSome(new Set(['testC'])), 'testC changed');
+			} catch (err) {
+				def.error(err);
+				return;
+			}
+
+			def.complete(undefined);
+		});
+
+		root.bufferChangeEvents(() => {
+			root.setContext('testA', 10);
+			root.setContext('testB', 20);
+			root.setContext('testC', 30);
+		});
+
+		return def.p;
+	});
 });