From 6fbb5922669ad5e61330900dfefd51d933243ecf Mon Sep 17 00:00:00 2001 From: antododo Date: Mon, 11 May 2026 12:12:16 +0200 Subject: [PATCH] fix(useFocusVisible): use Object.defineProperty to wrap HTMLElement.prototype.focus --- .../src/interactions/useFocusVisible.ts | 29 +++++++++++++++---- .../test/interactions/useFocusVisible.test.js | 20 +++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/react-aria/src/interactions/useFocusVisible.ts b/packages/react-aria/src/interactions/useFocusVisible.ts index 01ad4a74ec0..339527f10fc 100644 --- a/packages/react-aria/src/interactions/useFocusVisible.ts +++ b/packages/react-aria/src/interactions/useFocusVisible.ts @@ -166,11 +166,22 @@ function setupGlobalFocusEvents(element?: HTMLElement | null) { // However, we need to detect other cases when a focus event occurs without // a preceding user event (e.g. screen reader focus). Overriding the focus // method on HTMLElement.prototype is a bit hacky, but works. + // defineProperty (not assignment) so this works even if `focus` is currently + // a getter-only accessor — e.g. when @testing-library/user-event's setup() + // has instrumented it. Plain assignment throws in that case. let focus = windowObject.HTMLElement.prototype.focus; - windowObject.HTMLElement.prototype.focus = function () { - hasEventBeforeFocus = true; - focus.apply(this, arguments as unknown as [options?: FocusOptions | undefined]); - }; + try { + Object.defineProperty(windowObject.HTMLElement.prototype, 'focus', { + configurable: true, + writable: true, + value: function () { + hasEventBeforeFocus = true; + focus.apply(this, arguments as unknown as [options?: FocusOptions | undefined]); + } + }); + } catch { + // Non-configurable accessor: can't wrap. Other listeners still cover most cases. + } documentObject.addEventListener('keydown', handleKeyboardEvent, true); documentObject.addEventListener('keyup', handleKeyboardEvent, true); @@ -212,7 +223,15 @@ const tearDownWindowFocusTracking = (element, loadListener?: () => void) => { if (!hasSetupGlobalListeners.has(windowObject)) { return; } - windowObject.HTMLElement.prototype.focus = hasSetupGlobalListeners.get(windowObject)!.focus; + try { + Object.defineProperty(windowObject.HTMLElement.prototype, 'focus', { + configurable: true, + writable: true, + value: hasSetupGlobalListeners.get(windowObject)!.focus + }); + } catch { + // See setupGlobalFocusEvents. + } documentObject.removeEventListener('keydown', handleKeyboardEvent, true); documentObject.removeEventListener('keyup', handleKeyboardEvent, true); diff --git a/packages/react-aria/test/interactions/useFocusVisible.test.js b/packages/react-aria/test/interactions/useFocusVisible.test.js index 9f3d18eef3c..d469996ee35 100644 --- a/packages/react-aria/test/interactions/useFocusVisible.test.js +++ b/packages/react-aria/test/interactions/useFocusVisible.test.js @@ -148,6 +148,26 @@ describe('useFocusVisible', function () { iframe.remove(); }); + // Regression test for https://github.com/adobe/react-spectrum/issues/9649 + it('does not throw when HTMLElement.prototype.focus is an accessor-only property', function () { + const HTMLElementProto = iframe.contentWindow.HTMLElement.prototype; + const original = Object.getOwnPropertyDescriptor(HTMLElementProto, 'focus'); + Object.defineProperty(HTMLElementProto, 'focus', { + configurable: true, + get() { + return original?.value; + } + }); + + try { + expect(() => addWindowFocusTracking(iframeRoot)).not.toThrow(); + } finally { + if (original) { + Object.defineProperty(HTMLElementProto, 'focus', original); + } + } + }); + it('sets up focus listener in a different window', async function () { render(, {container: iframeRoot}); await waitFor(() => {