Skip to content

Commit 4c7036e

Browse files
authored
Bugfix: Infinite loop in beforeblur event (facebook#19053)
* Failing test: Infinite loop in beforeblur event If the focused node is hidden by a Suspense boundary, we fire the beforeblur event. Our check for whether a tree is being hidden isn't specific enough. It should only fire when the tree is initially hidden, but it's being fired for updates, too. * Only fire beforeblur on visible -> hidden Should only beforeblur fire if the node was previously visible. Not during updates to an already hidden tree. To optimize this, we should use a dedicated effect tag and mark it in the render phase. I've left this for a follow-up, though. Maybe can revisit after the planned refactor of the commit phase. * Move logic to commit phase isFiberSuspenseAndTimedOut is used elsewhere, so I inlined the commit logic into the commit phase itself.
1 parent 1d85bb3 commit 4c7036e

File tree

6 files changed

+145
-51
lines changed

6 files changed

+145
-51
lines changed

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2489,6 +2489,68 @@ describe('DOMModernPluginEventSystem', () => {
24892489
document.body.removeChild(container2);
24902490
});
24912491

2492+
// @gate experimental
2493+
it('regression: does not fire beforeblur/afterblur if target is already hidden', () => {
2494+
const Suspense = React.Suspense;
2495+
let suspend = false;
2496+
const promise = Promise.resolve();
2497+
const beforeBlurHandle = ReactDOM.unstable_createEventHandle(
2498+
'beforeblur',
2499+
);
2500+
const innerRef = React.createRef();
2501+
2502+
function Child() {
2503+
if (suspend) {
2504+
throw promise;
2505+
}
2506+
return <input ref={innerRef} />;
2507+
}
2508+
2509+
const Component = () => {
2510+
const ref = React.useRef(null);
2511+
const [, setState] = React.useState(0);
2512+
2513+
React.useEffect(() => {
2514+
beforeBlurHandle.setListener(ref.current, () => {
2515+
// In the regression case, this would trigger an update, then
2516+
// the resulting render would trigger another blur event,
2517+
// which would trigger an update again, and on and on in an
2518+
// infinite loop.
2519+
setState(n => n + 1);
2520+
});
2521+
}, []);
2522+
2523+
return (
2524+
<div ref={ref}>
2525+
<Suspense fallback="Loading...">
2526+
<Child />
2527+
</Suspense>
2528+
</div>
2529+
);
2530+
};
2531+
2532+
const container2 = document.createElement('div');
2533+
document.body.appendChild(container2);
2534+
2535+
const root = ReactDOM.createRoot(container2);
2536+
ReactTestUtils.act(() => {
2537+
root.render(<Component />);
2538+
});
2539+
2540+
// Focus the input node
2541+
const inner = innerRef.current;
2542+
const target = createEventTarget(inner);
2543+
target.focus();
2544+
2545+
// Suspend. This hides the input node, causing it to lose focus.
2546+
suspend = true;
2547+
ReactTestUtils.act(() => {
2548+
root.render(<Component />);
2549+
});
2550+
2551+
document.body.removeChild(container2);
2552+
});
2553+
24922554
describe('Compatibility with Scopes API', () => {
24932555
beforeEach(() => {
24942556
jest.resetModules();

packages/react-reconciler/src/ReactFiberCommitWork.new.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1752,6 +1752,23 @@ function attachSuspenseRetryListeners(finishedWork: Fiber) {
17521752
}
17531753
}
17541754

1755+
// This function detects when a Suspense boundary goes from visible to hidden.
1756+
// It returns false if the boundary is already hidden.
1757+
// TODO: Use an effect tag.
1758+
export function isSuspenseBoundaryBeingHidden(
1759+
current: Fiber | null,
1760+
finishedWork: Fiber,
1761+
): boolean {
1762+
if (current !== null) {
1763+
const oldState: SuspenseState | null = current.memoizedState;
1764+
if (oldState === null || oldState.dehydrated !== null) {
1765+
const newState: SuspenseState | null = finishedWork.memoizedState;
1766+
return newState !== null && newState.dehydrated === null;
1767+
}
1768+
}
1769+
return false;
1770+
}
1771+
17551772
function commitResetTextContent(current: Fiber) {
17561773
if (!supportsMutation) {
17571774
return;

packages/react-reconciler/src/ReactFiberCommitWork.old.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1735,6 +1735,23 @@ function attachSuspenseRetryListeners(finishedWork: Fiber) {
17351735
}
17361736
}
17371737

1738+
// This function detects when a Suspense boundary goes from visible to hidden.
1739+
// It returns false if the boundary is already hidden.
1740+
// TODO: Use an effect tag.
1741+
export function isSuspenseBoundaryBeingHidden(
1742+
current: Fiber | null,
1743+
finishedWork: Fiber,
1744+
): boolean {
1745+
if (current !== null) {
1746+
const oldState: SuspenseState | null = current.memoizedState;
1747+
if (oldState === null || oldState.dehydrated !== null) {
1748+
const newState: SuspenseState | null = finishedWork.memoizedState;
1749+
return newState !== null && newState.dehydrated === null;
1750+
}
1751+
}
1752+
return false;
1753+
}
1754+
17381755
function commitResetTextContent(current: Fiber) {
17391756
if (!supportsMutation) {
17401757
return;

packages/react-reconciler/src/ReactFiberTreeReflection.js

Lines changed: 5 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
FundamentalComponent,
2626
SuspenseComponent,
2727
} from './ReactWorkTags';
28-
import {NoEffect, Placement, Hydrating, Deletion} from './ReactSideEffectTags';
28+
import {NoEffect, Placement, Hydrating} from './ReactSideEffectTags';
2929
import {enableFundamentalAPI} from 'shared/ReactFeatureFlags';
3030

3131
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
@@ -342,7 +342,10 @@ export function isFiberSuspenseAndTimedOut(fiber: Fiber): boolean {
342342
);
343343
}
344344

345-
function doesFiberContain(parentFiber: Fiber, childFiber: Fiber): boolean {
345+
export function doesFiberContain(
346+
parentFiber: Fiber,
347+
childFiber: Fiber,
348+
): boolean {
346349
let node = childFiber;
347350
const parentFiberAlternate = parentFiber.alternate;
348351
while (node !== null) {
@@ -353,34 +356,3 @@ function doesFiberContain(parentFiber: Fiber, childFiber: Fiber): boolean {
353356
}
354357
return false;
355358
}
356-
357-
function isFiberTimedOutSuspenseThatContainsTargetFiber(
358-
fiber: Fiber,
359-
targetFiber: Fiber,
360-
): boolean {
361-
const child = fiber.child;
362-
return (
363-
isFiberSuspenseAndTimedOut(fiber) &&
364-
child !== null &&
365-
doesFiberContain(child, targetFiber)
366-
);
367-
}
368-
369-
function isFiberDeletedAndContainsTargetFiber(
370-
fiber: Fiber,
371-
targetFiber: Fiber,
372-
): boolean {
373-
return (
374-
(fiber.effectTag & Deletion) !== 0 && doesFiberContain(fiber, targetFiber)
375-
);
376-
}
377-
378-
export function isFiberHiddenOrDeletedAndContains(
379-
parentFiber: Fiber,
380-
childFiber: Fiber,
381-
): boolean {
382-
return (
383-
isFiberDeletedAndContainsTargetFiber(parentFiber, childFiber) ||
384-
isFiberTimedOutSuspenseThatContainsTargetFiber(parentFiber, childFiber)
385-
);
386-
}

packages/react-reconciler/src/ReactFiberWorkLoop.new.js

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ import {
159159
commitAttachRef,
160160
commitPassiveEffectDurations,
161161
commitResetTextContent,
162+
isSuspenseBoundaryBeingHidden,
162163
} from './ReactFiberCommitWork.new';
163164
import {enqueueUpdate} from './ReactUpdateQueue.new';
164165
import {resetContextDependencies} from './ReactFiberNewContext.new';
@@ -201,7 +202,7 @@ import {onCommitRoot as onCommitRootTestSelector} from './ReactTestSelectors';
201202

202203
// Used by `act`
203204
import enqueueTask from 'shared/enqueueTask';
204-
import {isFiberHiddenOrDeletedAndContains} from './ReactFiberTreeReflection';
205+
import {doesFiberContain} from './ReactFiberTreeReflection';
205206

206207
const ceil = Math.ceil;
207208

@@ -2102,19 +2103,31 @@ function commitRootImpl(root, renderPriorityLevel) {
21022103

21032104
function commitBeforeMutationEffects() {
21042105
while (nextEffect !== null) {
2105-
if (
2106-
!shouldFireAfterActiveInstanceBlur &&
2107-
focusedInstanceHandle !== null &&
2108-
isFiberHiddenOrDeletedAndContains(nextEffect, focusedInstanceHandle)
2109-
) {
2110-
shouldFireAfterActiveInstanceBlur = true;
2111-
beforeActiveInstanceBlur();
2106+
const current = nextEffect.alternate;
2107+
2108+
if (!shouldFireAfterActiveInstanceBlur && focusedInstanceHandle !== null) {
2109+
if ((nextEffect.effectTag & Deletion) !== NoEffect) {
2110+
if (doesFiberContain(nextEffect, focusedInstanceHandle)) {
2111+
shouldFireAfterActiveInstanceBlur = true;
2112+
beforeActiveInstanceBlur();
2113+
}
2114+
} else {
2115+
// TODO: Move this out of the hot path using a dedicated effect tag.
2116+
if (
2117+
nextEffect.tag === SuspenseComponent &&
2118+
isSuspenseBoundaryBeingHidden(current, nextEffect) &&
2119+
doesFiberContain(nextEffect, focusedInstanceHandle)
2120+
) {
2121+
shouldFireAfterActiveInstanceBlur = true;
2122+
beforeActiveInstanceBlur();
2123+
}
2124+
}
21122125
}
2126+
21132127
const effectTag = nextEffect.effectTag;
21142128
if ((effectTag & Snapshot) !== NoEffect) {
21152129
setCurrentDebugFiberInDEV(nextEffect);
21162130

2117-
const current = nextEffect.alternate;
21182131
commitBeforeMutationEffectOnFiber(current, nextEffect);
21192132

21202133
resetCurrentDebugFiberInDEV();

packages/react-reconciler/src/ReactFiberWorkLoop.old.js

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ import {
156156
commitAttachRef,
157157
commitPassiveEffectDurations,
158158
commitResetTextContent,
159+
isSuspenseBoundaryBeingHidden,
159160
} from './ReactFiberCommitWork.old';
160161
import {enqueueUpdate} from './ReactUpdateQueue.old';
161162
import {resetContextDependencies} from './ReactFiberNewContext.old';
@@ -193,7 +194,7 @@ import {onCommitRoot as onCommitRootTestSelector} from './ReactTestSelectors';
193194

194195
// Used by `act`
195196
import enqueueTask from 'shared/enqueueTask';
196-
import {isFiberHiddenOrDeletedAndContains} from './ReactFiberTreeReflection';
197+
import {doesFiberContain} from './ReactFiberTreeReflection';
197198

198199
const ceil = Math.ceil;
199200

@@ -2220,19 +2221,31 @@ function commitRootImpl(root, renderPriorityLevel) {
22202221

22212222
function commitBeforeMutationEffects() {
22222223
while (nextEffect !== null) {
2223-
if (
2224-
!shouldFireAfterActiveInstanceBlur &&
2225-
focusedInstanceHandle !== null &&
2226-
isFiberHiddenOrDeletedAndContains(nextEffect, focusedInstanceHandle)
2227-
) {
2228-
shouldFireAfterActiveInstanceBlur = true;
2229-
beforeActiveInstanceBlur();
2224+
const current = nextEffect.alternate;
2225+
2226+
if (!shouldFireAfterActiveInstanceBlur && focusedInstanceHandle !== null) {
2227+
if ((nextEffect.effectTag & Deletion) !== NoEffect) {
2228+
if (doesFiberContain(nextEffect, focusedInstanceHandle)) {
2229+
shouldFireAfterActiveInstanceBlur = true;
2230+
beforeActiveInstanceBlur();
2231+
}
2232+
} else {
2233+
// TODO: Move this out of the hot path using a dedicated effect tag.
2234+
if (
2235+
nextEffect.tag === SuspenseComponent &&
2236+
isSuspenseBoundaryBeingHidden(current, nextEffect) &&
2237+
doesFiberContain(nextEffect, focusedInstanceHandle)
2238+
) {
2239+
shouldFireAfterActiveInstanceBlur = true;
2240+
beforeActiveInstanceBlur();
2241+
}
2242+
}
22302243
}
2244+
22312245
const effectTag = nextEffect.effectTag;
22322246
if ((effectTag & Snapshot) !== NoEffect) {
22332247
setCurrentDebugFiberInDEV(nextEffect);
22342248

2235-
const current = nextEffect.alternate;
22362249
commitBeforeMutationEffectOnFiber(current, nextEffect);
22372250

22382251
resetCurrentDebugFiberInDEV();

0 commit comments

Comments
 (0)