Skip to content

Commit bb1b795

Browse files
authored
fix: don't run effects if a render phase update results in unchanged deps (#20676)
The memoized state of effect hooks is only invalidated when deps change. Deps are compared between the previous effect and the current effect. This can be problematic if one commit consists of an update that has changed deps followed by an update that has equal deps. That commit will lead to memoizedState containing the changed deps even though we committed with unchanged deps. The n+1 update will therefore run an effect because we compare the updated deps with the deps with which we never actually committed. To prevent this we now invalidate memoizedState on every updateEffectImpl call so that memoizedStat.deps always points to the latest deps.
1 parent 766a7a2 commit bb1b795

File tree

4 files changed

+90
-3
lines changed

4 files changed

+90
-3
lines changed

packages/react-devtools-shared/src/backend/renderer.js

+45-1
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import type {
103103
ComponentFilter,
104104
ElementType,
105105
} from 'react-devtools-shared/src/types';
106+
import is from 'shared/objectIs';
106107

107108
type getDisplayNameForFiberType = (fiber: Fiber) => string | null;
108109
type getTypeSymbolType = (type: any) => Symbol | number;
@@ -1073,6 +1074,49 @@ export function attach(
10731074
return null;
10741075
}
10751076

1077+
function areHookInputsEqual(
1078+
nextDeps: Array<mixed>,
1079+
prevDeps: Array<mixed> | null,
1080+
) {
1081+
if (prevDeps === null) {
1082+
return false;
1083+
}
1084+
1085+
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
1086+
if (is(nextDeps[i], prevDeps[i])) {
1087+
continue;
1088+
}
1089+
return false;
1090+
}
1091+
return true;
1092+
}
1093+
1094+
function isEffect(memoizedState) {
1095+
return (
1096+
memoizedState !== null &&
1097+
typeof memoizedState === 'object' &&
1098+
memoizedState.hasOwnProperty('tag') &&
1099+
memoizedState.hasOwnProperty('create') &&
1100+
memoizedState.hasOwnProperty('destroy') &&
1101+
memoizedState.hasOwnProperty('deps') &&
1102+
(memoizedState.deps === null || Array.isArray(memoizedState.deps)) &&
1103+
memoizedState.hasOwnProperty('next')
1104+
);
1105+
}
1106+
1107+
function didHookChange(prev: any, next: any): boolean {
1108+
const prevMemoizedState = prev.memoizedState;
1109+
const nextMemoizedState = next.memoizedState;
1110+
1111+
if (isEffect(prevMemoizedState) && isEffect(nextMemoizedState)) {
1112+
return (
1113+
prevMemoizedState !== nextMemoizedState &&
1114+
!areHookInputsEqual(nextMemoizedState.deps, prevMemoizedState.deps)
1115+
);
1116+
}
1117+
return nextMemoizedState !== prevMemoizedState;
1118+
}
1119+
10761120
function didHooksChange(prev: any, next: any): boolean {
10771121
if (prev == null || next == null) {
10781122
return false;
@@ -1086,7 +1130,7 @@ export function attach(
10861130
next.hasOwnProperty('queue')
10871131
) {
10881132
while (next !== null) {
1089-
if (next.memoizedState !== prev.memoizedState) {
1133+
if (didHookChange(prev, next)) {
10901134
return true;
10911135
} else {
10921136
next = next.next;

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -1365,7 +1365,7 @@ function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
13651365
if (nextDeps !== null) {
13661366
const prevDeps = prevEffect.deps;
13671367
if (areHookInputsEqual(nextDeps, prevDeps)) {
1368-
pushEffect(hookFlags, create, destroy, nextDeps);
1368+
hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
13691369
return;
13701370
}
13711371
}

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -1344,7 +1344,7 @@ function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
13441344
if (nextDeps !== null) {
13451345
const prevDeps = prevEffect.deps;
13461346
if (areHookInputsEqual(nextDeps, prevDeps)) {
1347-
pushEffect(hookFlags, create, destroy, nextDeps);
1347+
hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
13481348
return;
13491349
}
13501350
}

packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js

+43
Original file line numberDiff line numberDiff line change
@@ -3760,4 +3760,47 @@ describe('ReactHooksWithNoopRenderer', () => {
37603760
// effects would not fire.
37613761
expect(Scheduler).toHaveYielded(['Unmount A', 'Unmount B']);
37623762
});
3763+
3764+
it('effect dependencies are persisted after a render phase update', () => {
3765+
let handleClick;
3766+
function Test() {
3767+
const [count, setCount] = useState(0);
3768+
3769+
useEffect(() => {
3770+
Scheduler.unstable_yieldValue(`Effect: ${count}`);
3771+
}, [count]);
3772+
3773+
if (count > 0) {
3774+
setCount(0);
3775+
}
3776+
3777+
handleClick = () => setCount(2);
3778+
3779+
return <Text text={`Render: ${count}`} />;
3780+
}
3781+
3782+
ReactNoop.act(() => {
3783+
ReactNoop.render(<Test />);
3784+
});
3785+
3786+
expect(Scheduler).toHaveYielded(['Render: 0', 'Effect: 0']);
3787+
3788+
ReactNoop.act(() => {
3789+
handleClick();
3790+
});
3791+
3792+
expect(Scheduler).toHaveYielded(['Render: 0']);
3793+
3794+
ReactNoop.act(() => {
3795+
handleClick();
3796+
});
3797+
3798+
expect(Scheduler).toHaveYielded(['Render: 0']);
3799+
3800+
ReactNoop.act(() => {
3801+
handleClick();
3802+
});
3803+
3804+
expect(Scheduler).toHaveYielded(['Render: 0']);
3805+
});
37633806
});

0 commit comments

Comments
 (0)