Skip to content

Commit 6fbb592

Browse files
committed
fix(useFocusVisible): use Object.defineProperty to wrap HTMLElement.prototype.focus
1 parent 565e914 commit 6fbb592

2 files changed

Lines changed: 44 additions & 5 deletions

File tree

packages/react-aria/src/interactions/useFocusVisible.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,22 @@ function setupGlobalFocusEvents(element?: HTMLElement | null) {
166166
// However, we need to detect other cases when a focus event occurs without
167167
// a preceding user event (e.g. screen reader focus). Overriding the focus
168168
// method on HTMLElement.prototype is a bit hacky, but works.
169+
// defineProperty (not assignment) so this works even if `focus` is currently
170+
// a getter-only accessor — e.g. when @testing-library/user-event's setup()
171+
// has instrumented it. Plain assignment throws in that case.
169172
let focus = windowObject.HTMLElement.prototype.focus;
170-
windowObject.HTMLElement.prototype.focus = function () {
171-
hasEventBeforeFocus = true;
172-
focus.apply(this, arguments as unknown as [options?: FocusOptions | undefined]);
173-
};
173+
try {
174+
Object.defineProperty(windowObject.HTMLElement.prototype, 'focus', {
175+
configurable: true,
176+
writable: true,
177+
value: function () {
178+
hasEventBeforeFocus = true;
179+
focus.apply(this, arguments as unknown as [options?: FocusOptions | undefined]);
180+
}
181+
});
182+
} catch {
183+
// Non-configurable accessor: can't wrap. Other listeners still cover most cases.
184+
}
174185

175186
documentObject.addEventListener('keydown', handleKeyboardEvent, true);
176187
documentObject.addEventListener('keyup', handleKeyboardEvent, true);
@@ -212,7 +223,15 @@ const tearDownWindowFocusTracking = (element, loadListener?: () => void) => {
212223
if (!hasSetupGlobalListeners.has(windowObject)) {
213224
return;
214225
}
215-
windowObject.HTMLElement.prototype.focus = hasSetupGlobalListeners.get(windowObject)!.focus;
226+
try {
227+
Object.defineProperty(windowObject.HTMLElement.prototype, 'focus', {
228+
configurable: true,
229+
writable: true,
230+
value: hasSetupGlobalListeners.get(windowObject)!.focus
231+
});
232+
} catch {
233+
// See setupGlobalFocusEvents.
234+
}
216235

217236
documentObject.removeEventListener('keydown', handleKeyboardEvent, true);
218237
documentObject.removeEventListener('keyup', handleKeyboardEvent, true);

packages/react-aria/test/interactions/useFocusVisible.test.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,26 @@ describe('useFocusVisible', function () {
148148
iframe.remove();
149149
});
150150

151+
// Regression test for https://github.com/adobe/react-spectrum/issues/9649
152+
it('does not throw when HTMLElement.prototype.focus is an accessor-only property', function () {
153+
const HTMLElementProto = iframe.contentWindow.HTMLElement.prototype;
154+
const original = Object.getOwnPropertyDescriptor(HTMLElementProto, 'focus');
155+
Object.defineProperty(HTMLElementProto, 'focus', {
156+
configurable: true,
157+
get() {
158+
return original?.value;
159+
}
160+
});
161+
162+
try {
163+
expect(() => addWindowFocusTracking(iframeRoot)).not.toThrow();
164+
} finally {
165+
if (original) {
166+
Object.defineProperty(HTMLElementProto, 'focus', original);
167+
}
168+
}
169+
});
170+
151171
it('sets up focus listener in a different window', async function () {
152172
render(<Example id="iframe-example" />, {container: iframeRoot});
153173
await waitFor(() => {

0 commit comments

Comments
 (0)