Skip to content

button: clear hover style when a focused button is disabled (fixes screenshot flake) - #342255

Merged
Alexandru Dima (alexdima) merged 1 commit into
mainfrom
agents/stabilize-screenshot-flake-issue
Oct 17, 2026
Merged

Alexandru Dima (alexdima) merged 1 commit into
mainfrom
agents/stabilize-screenshot-flake-issue

Conversation

@alexdima

Copy link
Copy Markdown
Member

Stabilizes the flaky chat/aiCustomizations/aiCustomizationManagementEditor/PluginMarketplaceMigrationDisabled/Light screenshot. The flake showed up on #342242 (screenshot report), even though that PR only changes a unit test file. The root cause is a small bug in the base Button widget, which this PR fixes.

What changed in the screenshot

The only difference is the background of the disabled Migrate 0 button. In the base screenshot it is pure white. In the flaky one it is very slightly gray. 1,312 pixels changed, all inside the button.

Changed pixels in red (the rest of the page is faded so they stand out):

Overview with the changed pixels highlighted in red

Zoomed 6×: before, after, and the delta:

Zoomed before, after, and red delta of the Migrate 0 button

Original full-size CI screenshots
Before (baseline) After (flaky)
before after

The fill goes from rgb(255,255,255) to rgb(250,250,250). That matches list.hoverBackground showing through the disabled button's opacity: 0.4. So the disabled button was captured while still using its hover background.

Why it happens: a race between focus and disable

The fixture does two things whose order isn't deterministic:

  1. Auto-focus (app code). showCustomizationMigrationDashboard() focuses the first Migrate button in a requestAnimationFrame. In the fixture, that callback runs on virtual time.
  2. Deselect (fixture code). The pluginMarketplaceMigrationDeselected option waits for the checkbox using real animation frames (waitForFixtureCondition), then clicks it. This sets selectedCount = 0, which runs migrateButton.enabled = false.

Because one side runs on virtual time and the other on real frames, either can go first under CI load:

Order What Button does Result
Disable → focus onDidFocus checks if (this.enabled). The button is already disabled, so hover styling is skipped. White ✅ (baseline)
Focus → disable onDidFocus applies buttonSecondaryHoverBackground. Then the button is disabled, and nothing removes that background. Gray ❌ (flaky)

The bug is in Button:

this._register(this.focusTracker.onDidFocus(() => { if (this.enabled) { this.updateStyles(true); } }));
this._register(this.focusTracker.onDidBlur(() => { if (this.enabled) { this.updateStyles(false); } }));

Both the focus and blur handlers check enabled, and the enabled setter only toggles a class and ARIA attributes. So once a focused button is disabled, its hover background stays until it is re-enabled, even after focus moves away. This isn't limited to fixtures. In the real UI: open the migration dashboard (Migrate gets auto-focused), then uncheck the only item. The disabled Migrate 0 button keeps the hover tint, and moving focus away doesn't clear it.

The fix

Button now records whether it has applied its hover style, and the enabled = false setter resets to the normal style when it has:

} else {
	this._element.classList.add('disabled');
	this._element.setAttribute('aria-disabled', String(true));
	if (this._hoverStyleApplied) {
		this.updateStyles(false);
	}
}

The reset only runs when hover styling is actually applied, so disabling a button that isn't hovered or focused leaves its inline styles alone. This matters for callers that set a custom inline background, such as promptCodingAgentActionOverlay. Re-enabling and focus behavior are unchanged.

With this change, both orderings render the baseline (white) button, so the screenshot is deterministic again and the baseline doesn't need to change.

How it was verified

  • New unit test in src/vs/base/test/browser/ui/button/button.test.ts: hover the button, disable it, then check the background goes back to the normal color and that mouse-over while disabled doesn't re-apply hover. The test fails without the fix ('blue' instead of 'red') and passes with it.

    • ./scripts/test.sh --run src/vs/base/test/browser/ui/button/button.test.ts: passed
  • Forced repro of the race with the component explorer: I temporarily added .focus() on the Migrate button just before the fixture clicks the checkbox, which forces the "focus → disable" order, then rendered PluginMarketplaceMigrationDisabled:

    • without the fix: button fill rgb(250,250,250), the same as the flaky CI image
    • with the fix: button fill rgb(255,255,255), the same as the CI baseline

    I removed that temporary fixture change afterward; this PR doesn't modify the fixture.

  • Normal local renders: 8 runs with 8 workers, plus single-worker and headed runs. All produced the baseline locally, both with and without the fix. That's expected: the race depends on timing that I couldn't trigger locally without the forced focus.

  • npm run typecheck-client: passed

  • npm run eslint -- src/vs/base/browser/ui/button/button.ts src/vs/base/test/browser/ui/button/button.test.ts: passed

The blur handler only resets the hover background while the button is enabled, so a button that was focused (or hovered) and then disabled kept its hover background indefinitely. This made the PluginMarketplaceMigrationDisabled screenshot fixture flaky, depending on whether the dashboard's deferred auto-focus ran before or after the fixture deselected the item.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 17, 2026 09:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The narrowly scoped fix has regression coverage and no unresolved findings.

No open issues 🎉

What changed in this PR

Fixes stale hover styling on disabled buttons in the shared Button widget, stabilizing the migration dashboard screenshot.

Changes:

  • Tracks applied hover styling and clears it when disabling a button.
  • Adds regression coverage for disabling a hovered button and ignoring subsequent hover events.
File Description
src/​vs/​base/​test/​browser/​ui/​button/​button.test.ts Tests hover-background reset when disabled.
src/​vs/​base/​browser/​ui/​button/​button.ts Clears active hover styling without rewriting untouched styles.

Review effort: Balanced

@alexdima
Alexandru Dima (alexdima) marked this pull request as ready for review October 17, 2026 09:59
@alexdima
Alexandru Dima (alexdima) merged commit d7466d3 into main Oct 17, 2026
50 checks passed
@alexdima
Alexandru Dima (alexdima) deleted the agents/stabilize-screenshot-flake-issue branch October 17, 2026 10:16
@vs-code-engineering vs-code-engineering Bot added this to the 1.143.0 milestone Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants