Skip to content

Commit 1a6d817

Browse files
authored
[react-interactions] Ensure onBeforeBlur fires for hideInstance (#18064)
1 parent 48c4867 commit 1a6d817

File tree

2 files changed

+96
-10
lines changed

2 files changed

+96
-10
lines changed

packages/react-dom/src/client/ReactDOMHostConfig.js

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,17 @@ import {
5858
} from '../events/DeprecatedDOMEventResponderSystem';
5959
import {retryIfBlockedOn} from '../events/ReactDOMEventReplaying';
6060

61+
import {
62+
enableSuspenseServerRenderer,
63+
enableDeprecatedFlareAPI,
64+
enableFundamentalAPI,
65+
} from 'shared/ReactFeatureFlags';
66+
import {HostComponent} from 'shared/ReactWorkTags';
67+
import {
68+
RESPONDER_EVENT_SYSTEM,
69+
IS_PASSIVE,
70+
} from 'legacy-events/EventSystemFlags';
71+
6172
export type Type = string;
6273
export type Props = {
6374
autoFocus?: boolean,
@@ -112,16 +123,6 @@ type SelectionInformation = {|
112123
selectionRange: mixed,
113124
|};
114125

115-
import {
116-
enableSuspenseServerRenderer,
117-
enableDeprecatedFlareAPI,
118-
enableFundamentalAPI,
119-
} from 'shared/ReactFeatureFlags';
120-
import {
121-
RESPONDER_EVENT_SYSTEM,
122-
IS_PASSIVE,
123-
} from 'legacy-events/EventSystemFlags';
124-
125126
let SUPPRESS_HYDRATION_WARNING;
126127
if (__DEV__) {
127128
SUPPRESS_HYDRATION_WARNING = 'suppressHydrationWarning';
@@ -584,7 +585,28 @@ export function clearSuspenseBoundaryFromContainer(
584585
retryIfBlockedOn(container);
585586
}
586587

588+
function instanceContainsElem(instance: Instance, element: HTMLElement) {
589+
let fiber = getClosestInstanceFromNode(element);
590+
while (fiber !== null) {
591+
if (fiber.tag === HostComponent && fiber.stateNode === element) {
592+
return true;
593+
}
594+
fiber = fiber.return;
595+
}
596+
return false;
597+
}
598+
587599
export function hideInstance(instance: Instance): void {
600+
// Ensure we trigger `onBeforeBlur` if the active focused elment
601+
// is ether the instance of a child or the instance. We need
602+
// to traverse the Fiber tree here rather than use node.contains()
603+
// as the child node might be inside a Portal.
604+
if (enableDeprecatedFlareAPI && selectionInformation) {
605+
const focusedElem = selectionInformation.focusedElem;
606+
if (focusedElem !== null && instanceContainsElem(instance, focusedElem)) {
607+
dispatchBeforeDetachedBlur(((focusedElem: any): HTMLElement));
608+
}
609+
}
588610
// TODO: Does this work for all element types? What about MathML? Should we
589611
// pass host context to this method?
590612
instance = ((instance: any): HTMLElement);

packages/react-interactions/events/src/dom/__tests__/FocusWithin-test.internal.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ let ReactFeatureFlags;
1616
let ReactDOM;
1717
let FocusWithinResponder;
1818
let useFocusWithin;
19+
let Scheduler;
1920

2021
const initializeModules = hasPointerEvents => {
2122
setPointerEvent(hasPointerEvents);
@@ -27,6 +28,7 @@ const initializeModules = hasPointerEvents => {
2728
FocusWithinResponder = require('react-interactions/events/focus')
2829
.FocusWithinResponder;
2930
useFocusWithin = require('react-interactions/events/focus').useFocusWithin;
31+
Scheduler = require('scheduler');
3032
};
3133

3234
const forcePointerEvents = true;
@@ -336,6 +338,68 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => {
336338
expect.objectContaining({isTargetAttached: false}),
337339
);
338340
});
341+
342+
it.experimental(
343+
'is called after a focused suspended element is hidden',
344+
() => {
345+
const Suspense = React.Suspense;
346+
let suspend = false;
347+
let resolve;
348+
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
349+
350+
function Child() {
351+
if (suspend) {
352+
throw promise;
353+
} else {
354+
return <input ref={innerRef} />;
355+
}
356+
}
357+
358+
const Component = ({show}) => {
359+
const listener = useFocusWithin({
360+
onBeforeBlurWithin,
361+
onBlurWithin,
362+
});
363+
364+
return (
365+
<div DEPRECATED_flareListeners={listener}>
366+
<Suspense fallback="Loading...">
367+
<Child />
368+
</Suspense>
369+
</div>
370+
);
371+
};
372+
373+
const container2 = document.createElement('div');
374+
document.body.appendChild(container2);
375+
376+
let root = ReactDOM.createRoot(container2);
377+
root.render(<Component />);
378+
Scheduler.unstable_flushAll();
379+
jest.runAllTimers();
380+
expect(container2.innerHTML).toBe('<div><input></div>');
381+
382+
const inner = innerRef.current;
383+
const target = createEventTarget(inner);
384+
target.keydown({key: 'Tab'});
385+
target.focus();
386+
expect(onBeforeBlurWithin).toHaveBeenCalledTimes(0);
387+
expect(onBlurWithin).toHaveBeenCalledTimes(0);
388+
389+
suspend = true;
390+
root.render(<Component />);
391+
Scheduler.unstable_flushAll();
392+
jest.runAllTimers();
393+
expect(container2.innerHTML).toBe(
394+
'<div><input style="display: none;">Loading...</div>',
395+
);
396+
expect(onBeforeBlurWithin).toHaveBeenCalledTimes(1);
397+
expect(onBlurWithin).toHaveBeenCalledTimes(1);
398+
resolve();
399+
400+
document.body.removeChild(container2);
401+
},
402+
);
339403
});
340404

341405
it('expect displayName to show up for event component', () => {

0 commit comments

Comments
 (0)