Refine the heuristics around beforeblur/afterblur (#18668)

* Refine the heuristics around beforeblur/afterblur
This commit is contained in:
Dominic Gannaway
2020-04-20 19:32:22 +01:00
committed by GitHub
parent 707478e68a
commit a152827ef6
14 changed files with 381 additions and 52 deletions

View File

@@ -306,6 +306,7 @@ export function getPublicInstance(instance) {
export function prepareForCommit() {
// Noop
return null;
}
export function prepareUpdate(domElement, type, oldProps, newProps) {
@@ -507,3 +508,11 @@ export function unmountEventListener(listener: any) {
export function validateEventListenerTarget(target: any, listener: any) {
throw new Error('Not yet implemented.');
}
export function beforeActiveInstanceBlur() {
// noop
}
export function afterActiveInstanceBlur() {
// noop
}

View File

@@ -91,10 +91,6 @@ import {
import {getListenerMapForElement} from '../events/DOMEventListenerMap';
import {TOP_BEFORE_BLUR, TOP_AFTER_BLUR} from '../events/DOMTopLevelEventTypes';
// TODO: This is an exposed internal, we should move this around
// so this isn't the case.
import {isFiberInsideHiddenOrRemovedTree} from 'react-reconciler/src/ReactFiberTreeReflection';
export type ReactListenerEvent = ReactDOMListenerEvent;
export type ReactListenerMap = ReactDOMListenerMap;
export type ReactListener = ReactDOMListener;
@@ -159,7 +155,6 @@ export opaque type OpaqueIDType =
};
type SelectionInformation = {|
activeElementDetached: null | HTMLElement,
focusedElem: null | HTMLElement,
selectionRange: mixed,
|};
@@ -247,32 +242,40 @@ export function getPublicInstance(instance: Instance): * {
return instance;
}
export function prepareForCommit(containerInfo: Container): void {
export function prepareForCommit(containerInfo: Container): Object | null {
eventsEnabled = ReactBrowserEventEmitterIsEnabled();
selectionInformation = getSelectionInformation();
let activeInstance = null;
if (enableDeprecatedFlareAPI || enableUseEventAPI) {
const focusedElem = selectionInformation.focusedElem;
if (focusedElem !== null) {
const instance = getClosestInstanceFromNode(focusedElem);
if (instance !== null && isFiberInsideHiddenOrRemovedTree(instance)) {
dispatchBeforeDetachedBlur(focusedElem);
}
activeInstance = getClosestInstanceFromNode(focusedElem);
}
}
ReactBrowserEventEmitterSetEnabled(false);
return activeInstance;
}
export function beforeActiveInstanceBlur(): void {
if (enableDeprecatedFlareAPI || enableUseEventAPI) {
ReactBrowserEventEmitterSetEnabled(true);
dispatchBeforeDetachedBlur((selectionInformation: any).focusedElem);
ReactBrowserEventEmitterSetEnabled(false);
}
}
export function afterActiveInstanceBlur(): void {
if (enableDeprecatedFlareAPI || enableUseEventAPI) {
ReactBrowserEventEmitterSetEnabled(true);
dispatchAfterDetachedBlur((selectionInformation: any).focusedElem);
ReactBrowserEventEmitterSetEnabled(false);
}
}
export function resetAfterCommit(containerInfo: Container): void {
restoreSelection(selectionInformation);
ReactBrowserEventEmitterSetEnabled(eventsEnabled);
eventsEnabled = null;
if (enableDeprecatedFlareAPI || enableUseEventAPI) {
const activeElementDetached = (selectionInformation: any)
.activeElementDetached;
if (activeElementDetached !== null) {
dispatchAfterDetachedBlur(activeElementDetached);
}
}
selectionInformation = null;
}
@@ -525,8 +528,6 @@ function createEvent(type: TopLevelType): Event {
}
function dispatchBeforeDetachedBlur(target: HTMLElement): void {
((selectionInformation: any): SelectionInformation).activeElementDetached = target;
if (enableDeprecatedFlareAPI || enableUseEventAPI) {
const event = createEvent(TOP_BEFORE_BLUR);
// Dispatch "beforeblur" directly on the target,

View File

@@ -100,8 +100,6 @@ export function hasSelectionCapabilities(elem) {
export function getSelectionInformation() {
const focusedElem = getActiveElementDeep();
return {
// Used by Flare
activeElementDetached: null,
focusedElem: focusedElem,
selectionRange: hasSelectionCapabilities(focusedElem)
? getSelection(focusedElem)

View File

@@ -16,7 +16,8 @@ let ReactFeatureFlags;
let ReactDOM;
let FocusWithinResponder;
let useFocusWithin;
let Scheduler;
let ReactTestRenderer;
let act;
const initializeModules = hasPointerEvents => {
setPointerEvent(hasPointerEvents);
@@ -26,7 +27,8 @@ const initializeModules = hasPointerEvents => {
ReactFeatureFlags.enableScopeAPI = true;
React = require('react');
ReactDOM = require('react-dom');
Scheduler = require('scheduler');
ReactTestRenderer = require('react-test-renderer');
act = ReactTestRenderer.act;
// TODO: This import throws outside of experimental mode. Figure out better
// strategy for gated imports.
@@ -43,17 +45,22 @@ const table = [[forcePointerEvents], [!forcePointerEvents]];
describe.each(table)('FocusWithin responder', hasPointerEvents => {
let container;
let container2;
beforeEach(() => {
initializeModules();
container = document.createElement('div');
document.body.appendChild(container);
container2 = document.createElement('div');
document.body.appendChild(container2);
});
afterEach(() => {
ReactDOM.render(null, container);
document.body.removeChild(container);
document.body.removeChild(container2);
container = null;
container2 = null;
});
describe('disabled', () => {
@@ -366,6 +373,40 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => {
);
});
// @gate experimental
it('is called after many elements are unmounted', () => {
const buttonRef = React.createRef();
const inputRef = React.createRef();
const Component = ({show}) => {
const listener = useFocusWithin({
onBeforeBlurWithin,
onAfterBlurWithin,
});
return (
<div ref={ref} DEPRECATED_flareListeners={listener}>
{show && <button>Press me!</button>}
{show && <button>Press me!</button>}
{show && <input ref={inputRef} />}
{show && <button>Press me!</button>}
{!show && <button ref={buttonRef}>Press me!</button>}
{show && <button>Press me!</button>}
<button>Press me!</button>
<button>Press me!</button>
</div>
);
};
ReactDOM.render(<Component show={true} />, container);
inputRef.current.focus();
expect(onBeforeBlurWithin).toHaveBeenCalledTimes(0);
expect(onAfterBlurWithin).toHaveBeenCalledTimes(0);
ReactDOM.render(<Component show={false} />, container);
expect(onBeforeBlurWithin).toHaveBeenCalledTimes(1);
expect(onAfterBlurWithin).toHaveBeenCalledTimes(1);
});
// @gate experimental
it('is called after a nested focused element is unmounted (with scope query)', () => {
const TestScope = React.unstable_createScope();
@@ -430,12 +471,10 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => {
);
};
const container2 = document.createElement('div');
document.body.appendChild(container2);
const root = ReactDOM.createRoot(container2);
root.render(<Component />);
Scheduler.unstable_flushAll();
act(() => {
root.render(<Component />);
});
jest.runAllTimers();
expect(container2.innerHTML).toBe('<div><input></div>');
@@ -447,8 +486,9 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => {
expect(onAfterBlurWithin).toHaveBeenCalledTimes(0);
suspend = true;
root.render(<Component />);
Scheduler.unstable_flushAll();
act(() => {
root.render(<Component />);
});
jest.runAllTimers();
expect(container2.innerHTML).toBe(
'<div><input style="display: none;">Loading...</div>',
@@ -456,8 +496,74 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => {
expect(onBeforeBlurWithin).toHaveBeenCalledTimes(1);
expect(onAfterBlurWithin).toHaveBeenCalledTimes(1);
resolve();
});
document.body.removeChild(container2);
// @gate experimental
it('is called after a focused suspended element is hidden then shown', () => {
const Suspense = React.Suspense;
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const buttonRef = React.createRef();
function Child() {
if (suspend) {
throw promise;
} else {
return <input ref={innerRef} />;
}
}
const Component = ({show}) => {
const listener = useFocusWithin({
onBeforeBlurWithin,
onAfterBlurWithin,
});
return (
<div ref={ref} DEPRECATED_flareListeners={listener}>
<Suspense fallback={<button ref={buttonRef}>Loading...</button>}>
<Child />
</Suspense>
</div>
);
};
const root = ReactDOM.createRoot(container2);
act(() => {
root.render(<Component />);
});
jest.runAllTimers();
expect(onBeforeBlurWithin).toHaveBeenCalledTimes(0);
expect(onAfterBlurWithin).toHaveBeenCalledTimes(0);
suspend = true;
act(() => {
root.render(<Component />);
});
jest.runAllTimers();
expect(onBeforeBlurWithin).toHaveBeenCalledTimes(0);
expect(onAfterBlurWithin).toHaveBeenCalledTimes(0);
act(() => {
root.render(<Component />);
});
jest.runAllTimers();
expect(onBeforeBlurWithin).toHaveBeenCalledTimes(0);
expect(onAfterBlurWithin).toHaveBeenCalledTimes(0);
buttonRef.current.focus();
suspend = false;
act(() => {
root.render(<Component />);
});
jest.runAllTimers();
expect(onBeforeBlurWithin).toHaveBeenCalledTimes(1);
expect(onAfterBlurWithin).toHaveBeenCalledTimes(1);
resolve();
});
});

View File

@@ -42,17 +42,22 @@ const table = [[forcePointerEvents], [!forcePointerEvents]];
describe.each(table)(`useFocus`, hasPointerEvents => {
let container;
let container2;
beforeEach(() => {
initializeModules(hasPointerEvents);
container = document.createElement('div');
document.body.appendChild(container);
container2 = document.createElement('div');
document.body.appendChild(container2);
});
afterEach(() => {
ReactDOM.render(null, container);
document.body.removeChild(container);
document.body.removeChild(container2);
container = null;
container2 = null;
});
describe('disabled', () => {
@@ -367,6 +372,40 @@ describe.each(table)(`useFocus`, hasPointerEvents => {
);
});
// @gate experimental
it('is called after many elements are unmounted', () => {
const buttonRef = React.createRef();
const inputRef = React.createRef();
const Component = ({show}) => {
useFocusWithin(ref, {
onBeforeBlurWithin,
onAfterBlurWithin,
});
return (
<div ref={ref}>
{show && <button>Press me!</button>}
{show && <button>Press me!</button>}
{show && <input ref={inputRef} />}
{show && <button>Press me!</button>}
{!show && <button ref={buttonRef}>Press me!</button>}
{show && <button>Press me!</button>}
<button>Press me!</button>
<button>Press me!</button>
</div>
);
};
ReactDOM.render(<Component show={true} />, container);
inputRef.current.focus();
expect(onBeforeBlurWithin).toHaveBeenCalledTimes(0);
expect(onAfterBlurWithin).toHaveBeenCalledTimes(0);
ReactDOM.render(<Component show={false} />, container);
expect(onBeforeBlurWithin).toHaveBeenCalledTimes(1);
expect(onAfterBlurWithin).toHaveBeenCalledTimes(1);
});
// @gate experimental
it('is called after a nested focused element is unmounted (with scope query)', () => {
const TestScope = React.unstable_createScope();
@@ -431,9 +470,6 @@ describe.each(table)(`useFocus`, hasPointerEvents => {
);
};
const container2 = document.createElement('div');
document.body.appendChild(container2);
const root = ReactDOM.createRoot(container2);
act(() => {
@@ -460,8 +496,74 @@ describe.each(table)(`useFocus`, hasPointerEvents => {
expect(onBeforeBlurWithin).toHaveBeenCalledTimes(1);
expect(onAfterBlurWithin).toHaveBeenCalledTimes(1);
resolve();
});
document.body.removeChild(container2);
// @gate experimental
it('is called after a focused suspended element is hidden then shown', () => {
const Suspense = React.Suspense;
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const buttonRef = React.createRef();
function Child() {
if (suspend) {
throw promise;
} else {
return <input ref={innerRef} />;
}
}
const Component = ({show}) => {
useFocusWithin(ref, {
onBeforeBlurWithin,
onAfterBlurWithin,
});
return (
<div ref={ref}>
<Suspense fallback={<button ref={buttonRef}>Loading...</button>}>
<Child />
</Suspense>
</div>
);
};
const root = ReactDOM.createRoot(container2);
act(() => {
root.render(<Component />);
});
jest.runAllTimers();
expect(onBeforeBlurWithin).toHaveBeenCalledTimes(0);
expect(onAfterBlurWithin).toHaveBeenCalledTimes(0);
suspend = true;
act(() => {
root.render(<Component />);
});
jest.runAllTimers();
expect(onBeforeBlurWithin).toHaveBeenCalledTimes(0);
expect(onAfterBlurWithin).toHaveBeenCalledTimes(0);
act(() => {
root.render(<Component />);
});
jest.runAllTimers();
expect(onBeforeBlurWithin).toHaveBeenCalledTimes(0);
expect(onAfterBlurWithin).toHaveBeenCalledTimes(0);
buttonRef.current.focus();
suspend = false;
act(() => {
root.render(<Component />);
});
jest.runAllTimers();
expect(onBeforeBlurWithin).toHaveBeenCalledTimes(1);
expect(onAfterBlurWithin).toHaveBeenCalledTimes(1);
resolve();
});
});
});

View File

@@ -304,8 +304,9 @@ export function getPublicInstance(instance: Instance): * {
return instance.canonical;
}
export function prepareForCommit(containerInfo: Container): void {
export function prepareForCommit(containerInfo: Container): null | Object {
// Noop
return null;
}
export function prepareUpdate(
@@ -524,3 +525,11 @@ export function unmountEventListener(listener: any) {
export function validateEventListenerTarget(target: any, listener: any) {
throw new Error('Not yet implemented.');
}
export function beforeActiveInstanceBlur() {
// noop
}
export function afterActiveInstanceBlur() {
// noop
}

View File

@@ -223,8 +223,9 @@ export function getPublicInstance(instance: Instance): * {
return instance;
}
export function prepareForCommit(containerInfo: Container): void {
export function prepareForCommit(containerInfo: Container): null | Object {
// Noop
return null;
}
export function prepareUpdate(
@@ -573,3 +574,11 @@ export function unmountEventListener(listener: any) {
export function validateEventListenerTarget(target: any, listener: any) {
throw new Error('Not yet implemented.');
}
export function beforeActiveInstanceBlur() {
// noop
}
export function afterActiveInstanceBlur() {
// noop
}

View File

@@ -363,7 +363,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
cancelTimeout: clearTimeout,
noTimeout: -1,
prepareForCommit(): void {},
prepareForCommit(): null | Object {
return null;
},
resetAfterCommit(): void {},
@@ -439,6 +441,14 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
beforeRemoveInstance(instance: any): void {
// NO-OP
},
beforeActiveInstanceBlur() {
// NO-OP
},
afterActiveInstanceBlur() {
// NO-OP
},
};
const hostConfig = useMutation

View File

@@ -342,20 +342,45 @@ export function isFiberSuspenseAndTimedOut(fiber: Fiber): boolean {
);
}
// This is only safe to call in the commit phase when the return tree is consistent.
// It should not be used anywhere else. See PR #18609 for details.
export function isFiberInsideHiddenOrRemovedTree(fiber: Fiber): boolean {
let node = fiber;
let lastChild = null;
function doesFiberContain(parentFiber: Fiber, childFiber: Fiber): boolean {
let node = childFiber;
const parentFiberAlternate = parentFiber.alternate;
while (node !== null) {
if (
node.effectTag & Deletion ||
(isFiberSuspenseAndTimedOut(node) && node.child === lastChild)
) {
if (node === parentFiber || node === parentFiberAlternate) {
return true;
}
lastChild = node;
node = node.return;
}
return false;
}
function isFiberTimedOutSuspenseThatContainsTargetFiber(
fiber: Fiber,
targetFiber: Fiber,
): boolean {
const child = fiber.child;
return (
isFiberSuspenseAndTimedOut(fiber) &&
child !== null &&
doesFiberContain(child, targetFiber)
);
}
function isFiberDeletedAndContainsTargetFiber(
fiber: Fiber,
targetFiber: Fiber,
): boolean {
return (
(fiber.effectTag & Deletion) !== 0 && doesFiberContain(fiber, targetFiber)
);
}
export function isFiberHiddenOrDeletedAndContains(
parentFiber: Fiber,
childFiber: Fiber,
): boolean {
return (
isFiberDeletedAndContainsTargetFiber(parentFiber, childFiber) ||
isFiberTimedOutSuspenseThatContainsTargetFiber(parentFiber, childFiber)
);
}

View File

@@ -72,6 +72,8 @@ import {
cancelTimeout,
noTimeout,
warnsIfNotActing,
beforeActiveInstanceBlur,
afterActiveInstanceBlur,
} from './ReactFiberHostConfig';
import {
@@ -193,6 +195,7 @@ import {onCommitRoot} from './ReactFiberDevToolsHook.new';
// Used by `act`
import enqueueTask from 'shared/enqueueTask';
import {isFiberHiddenOrDeletedAndContains} from './ReactFiberTreeReflection';
const ceil = Math.ceil;
@@ -298,6 +301,9 @@ let currentEventTime: ExpirationTime = NoWork;
// We warn about state updates for unmounted components differently in this case.
let isFlushingPassiveEffects = false;
let focusedInstanceHandle: null | Fiber = null;
let shouldFireAfterActiveInstanceBlur: boolean = false;
export function getWorkInProgressRoot(): FiberRoot | null {
return workInProgressRoot;
}
@@ -1902,7 +1908,9 @@ function commitRootImpl(root, renderPriorityLevel) {
// The first phase a "before mutation" phase. We use this phase to read the
// state of the host tree right before we mutate it. This is where
// getSnapshotBeforeUpdate is called.
prepareForCommit(root.containerInfo);
focusedInstanceHandle = prepareForCommit(root.containerInfo);
shouldFireAfterActiveInstanceBlur = false;
nextEffect = firstEffect;
do {
if (__DEV__) {
@@ -1924,6 +1932,9 @@ function commitRootImpl(root, renderPriorityLevel) {
}
} while (nextEffect !== null);
// We no longer need to track the active instance fiber
focusedInstanceHandle = null;
if (enableProfilerTimer) {
// Mark the current commit time to be shared by all Profilers in this
// batch. This enables them to be grouped later.
@@ -1957,6 +1968,10 @@ function commitRootImpl(root, renderPriorityLevel) {
}
}
} while (nextEffect !== null);
if (shouldFireAfterActiveInstanceBlur) {
afterActiveInstanceBlur();
}
resetAfterCommit(root.containerInfo);
// The work-in-progress tree is now the current tree. This must come after
@@ -2124,6 +2139,14 @@ function commitRootImpl(root, renderPriorityLevel) {
function commitBeforeMutationEffects() {
while (nextEffect !== null) {
if (
!shouldFireAfterActiveInstanceBlur &&
focusedInstanceHandle !== null &&
isFiberHiddenOrDeletedAndContains(nextEffect, focusedInstanceHandle)
) {
shouldFireAfterActiveInstanceBlur = true;
beforeActiveInstanceBlur();
}
const effectTag = nextEffect.effectTag;
if ((effectTag & Snapshot) !== NoEffect) {
setCurrentDebugFiberInDEV(nextEffect);

View File

@@ -72,6 +72,8 @@ import {
cancelTimeout,
noTimeout,
warnsIfNotActing,
beforeActiveInstanceBlur,
afterActiveInstanceBlur,
} from './ReactFiberHostConfig';
import {
@@ -191,6 +193,7 @@ import {onCommitRoot} from './ReactFiberDevToolsHook.old';
// Used by `act`
import enqueueTask from 'shared/enqueueTask';
import {isFiberHiddenOrDeletedAndContains} from './ReactFiberTreeReflection';
const ceil = Math.ceil;
@@ -296,6 +299,9 @@ let currentEventTime: ExpirationTime = NoWork;
// We warn about state updates for unmounted components differently in this case.
let isFlushingPassiveEffects = false;
let focusedInstanceHandle: null | Fiber = null;
let shouldFireAfterActiveInstanceBlur: boolean = false;
export function getWorkInProgressRoot(): FiberRoot | null {
return workInProgressRoot;
}
@@ -1921,7 +1927,9 @@ function commitRootImpl(root, renderPriorityLevel) {
// The first phase a "before mutation" phase. We use this phase to read the
// state of the host tree right before we mutate it. This is where
// getSnapshotBeforeUpdate is called.
prepareForCommit(root.containerInfo);
focusedInstanceHandle = prepareForCommit(root.containerInfo);
shouldFireAfterActiveInstanceBlur = false;
nextEffect = firstEffect;
do {
if (__DEV__) {
@@ -1943,6 +1951,9 @@ function commitRootImpl(root, renderPriorityLevel) {
}
} while (nextEffect !== null);
// We no longer need to track the active instance fiber
focusedInstanceHandle = null;
if (enableProfilerTimer) {
// Mark the current commit time to be shared by all Profilers in this
// batch. This enables them to be grouped later.
@@ -1976,6 +1987,10 @@ function commitRootImpl(root, renderPriorityLevel) {
}
}
} while (nextEffect !== null);
if (shouldFireAfterActiveInstanceBlur) {
afterActiveInstanceBlur();
}
resetAfterCommit(root.containerInfo);
// The work-in-progress tree is now the current tree. This must come after
@@ -2143,6 +2158,14 @@ function commitRootImpl(root, renderPriorityLevel) {
function commitBeforeMutationEffects() {
while (nextEffect !== null) {
if (
!shouldFireAfterActiveInstanceBlur &&
focusedInstanceHandle !== null &&
isFiberHiddenOrDeletedAndContains(nextEffect, focusedInstanceHandle)
) {
shouldFireAfterActiveInstanceBlur = true;
beforeActiveInstanceBlur();
}
const effectTag = nextEffect.effectTag;
if ((effectTag & Snapshot) !== NoEffect) {
setCurrentDebugFiberInDEV(nextEffect);

View File

@@ -25,7 +25,9 @@ describe('ReactFiberHostContext', () => {
it('works with null host context', () => {
let creates = 0;
const Renderer = ReactFiberReconciler({
prepareForCommit: function() {},
prepareForCommit: function() {
return null;
},
resetAfterCommit: function() {},
getRootHostContext: function() {
return null;
@@ -76,6 +78,7 @@ describe('ReactFiberHostContext', () => {
const Renderer = ReactFiberReconciler({
prepareForCommit: function(hostContext) {
expect(hostContext).toBe(rootContext);
return null;
},
resetAfterCommit: function(hostContext) {
expect(hostContext).toBe(rootContext);

View File

@@ -89,6 +89,8 @@ export const makeOpaqueHydratingObject =
export const makeClientId = $$$hostConfig.makeClientId;
export const makeClientIdInDEV = $$$hostConfig.makeClientIdInDEV;
export const makeServerId = $$$hostConfig.makeServerId;
export const beforeActiveInstanceBlur = $$$hostConfig.beforeActiveInstanceBlur;
export const afterActiveInstanceBlur = $$$hostConfig.afterActiveInstanceBlur;
// -------------------
// Mutation

View File

@@ -143,8 +143,9 @@ export function getChildHostContext(
return NO_CONTEXT;
}
export function prepareForCommit(containerInfo: Container): void {
export function prepareForCommit(containerInfo: Container): null | Object {
// noop
return null;
}
export function resetAfterCommit(containerInfo: Container): void {
@@ -445,3 +446,11 @@ export function unmountEventListener(listener: any) {
export function validateEventListenerTarget(target: any, listener: any) {
throw new Error('Not yet implemented.');
}
export function beforeActiveInstanceBlur() {
// noop
}
export function afterActiveInstanceBlur() {
// noop
}