@samitouri / QOS-React-2 / commits / 4c7036e807

Bugfix: Infinite loop in beforeblur event (#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.

Andrew Clark committed Jun 1, 2020 at 09:01 UTC 4c7036e807fa18a3e21a5182983c7c0f05c5936e
6 files changed +145 -51
packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js
+62
@@ -2489,6 +2489,68 @@ describe('DOMModernPluginEventSystem', () => {
2489 document.body.removeChild(container2);
2490 });
2491
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 +
2554 describe('Compatibility with Scopes API', () => {
2555 beforeEach(() => {
2556 jest.resetModules();
packages/react-reconciler/src/ReactFiberCommitWork.new.js
+17
@@ -1752,6 +1752,23 @@ function attachSuspenseRetryListeners(finishedWork: Fiber) {
1752 }
1753 }
1754
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 +
1772 function commitResetTextContent(current: Fiber) {
1773 if (!supportsMutation) {
1774 return;
packages/react-reconciler/src/ReactFiberCommitWork.old.js
+17
@@ -1735,6 +1735,23 @@ function attachSuspenseRetryListeners(finishedWork: Fiber) {
1735 }
1736 }
1737
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 +
1755 function commitResetTextContent(current: Fiber) {
1756 if (!supportsMutation) {
1757 return;
packages/react-reconciler/src/ReactFiberTreeReflection.js
+5 -33
@@ -25,7 +25,7 @@ import {
25 FundamentalComponent,
26 SuspenseComponent,
27 } from './ReactWorkTags';
28 -import {NoEffect, Placement, Hydrating, Deletion} from './ReactSideEffectTags';
28 +import {NoEffect, Placement, Hydrating} from './ReactSideEffectTags';
29 import {enableFundamentalAPI} from 'shared/ReactFeatureFlags';
30
31 const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
@@ -342,7 +342,10 @@ export function isFiberSuspenseAndTimedOut(fiber: Fiber): boolean {
342 );
343 }
344
345 -function doesFiberContain(parentFiber: Fiber, childFiber: Fiber): boolean {
345 +export function doesFiberContain(
346 + parentFiber: Fiber,
347 + childFiber: Fiber,
348 +): boolean {
349 let node = childFiber;
350 const parentFiberAlternate = parentFiber.alternate;
351 while (node !== null) {
@@ -353,34 +356,3 @@ function doesFiberContain(parentFiber: Fiber, childFiber: Fiber): boolean {
356 }
357 return false;
358 }
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
+22 -9
@@ -159,6 +159,7 @@ import {
159 commitAttachRef,
160 commitPassiveEffectDurations,
161 commitResetTextContent,
162 + isSuspenseBoundaryBeingHidden,
163 } from './ReactFiberCommitWork.new';
164 import {enqueueUpdate} from './ReactUpdateQueue.new';
165 import {resetContextDependencies} from './ReactFiberNewContext.new';
@@ -201,7 +202,7 @@ import {onCommitRoot as onCommitRootTestSelector} from './ReactTestSelectors';
202
203 // Used by `act`
204 import enqueueTask from 'shared/enqueueTask';
204 -import {isFiberHiddenOrDeletedAndContains} from './ReactFiberTreeReflection';
205 +import {doesFiberContain} from './ReactFiberTreeReflection';
206
207 const ceil = Math.ceil;
208
@@ -2102,19 +2103,31 @@ function commitRootImpl(root, renderPriorityLevel) {
2103
2104 function commitBeforeMutationEffects() {
2105 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 + }
2125 }
2126 +
2127 const effectTag = nextEffect.effectTag;
2128 if ((effectTag & Snapshot) !== NoEffect) {
2129 setCurrentDebugFiberInDEV(nextEffect);
2130
2117 - const current = nextEffect.alternate;
2131 commitBeforeMutationEffectOnFiber(current, nextEffect);
2132
2133 resetCurrentDebugFiberInDEV();
packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+22 -9
@@ -156,6 +156,7 @@ import {
156 commitAttachRef,
157 commitPassiveEffectDurations,
158 commitResetTextContent,
159 + isSuspenseBoundaryBeingHidden,
160 } from './ReactFiberCommitWork.old';
161 import {enqueueUpdate} from './ReactUpdateQueue.old';
162 import {resetContextDependencies} from './ReactFiberNewContext.old';
@@ -193,7 +194,7 @@ import {onCommitRoot as onCommitRootTestSelector} from './ReactTestSelectors';
194
195 // Used by `act`
196 import enqueueTask from 'shared/enqueueTask';
196 -import {isFiberHiddenOrDeletedAndContains} from './ReactFiberTreeReflection';
197 +import {doesFiberContain} from './ReactFiberTreeReflection';
198
199 const ceil = Math.ceil;
200
@@ -2220,19 +2221,31 @@ function commitRootImpl(root, renderPriorityLevel) {
2221
2222 function commitBeforeMutationEffects() {
2223 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 + }
2243 }
2244 +
2245 const effectTag = nextEffect.effectTag;
2246 if ((effectTag & Snapshot) !== NoEffect) {
2247 setCurrentDebugFiberInDEV(nextEffect);
2248
2235 - const current = nextEffect.alternate;
2249 commitBeforeMutationEffectOnFiber(current, nextEffect);
2250
2251 resetCurrentDebugFiberInDEV();