Skip to content

Commit 05dce75

Browse files
authored
Fix priority of clean-up function on deletion (#16277)
The clean-up function of a passive effect (`useEffect`) usually fires in a post-commit task, after the browser has painted. However, there is an exception when the component (or its parent) is deleted from the tree. In that case, we fire the clean-up function during the synchronous commit phase, the same phase we use for layout effects. This is a concession to implementation complexity. Calling it in the passive effect phase would require either traversing the children of the deleted fiber again, or including unmount effects as part of the fiber effect list. Because the functions are called during the sync phase in this case, the Scheduler priority is Immediate (the one used for layout) instead of Normal. We may want to reconsider this trade off later.
1 parent a53f5cc commit 05dce75

File tree

3 files changed

+110
-23
lines changed

3 files changed

+110
-23
lines changed

packages/react-reconciler/src/ReactFiberCommitWork.js

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {CapturedValue, CapturedError} from './ReactCapturedValue';
2222
import type {SuspenseState} from './ReactFiberSuspenseComponent';
2323
import type {FunctionComponentUpdateQueue} from './ReactFiberHooks';
2424
import type {Thenable} from './ReactFiberWorkLoop';
25+
import type {ReactPriorityLevel} from './SchedulerWithReactIntegration';
2526

2627
import {unstable_wrap as Schedule_tracing_wrap} from 'scheduler/tracing';
2728
import {
@@ -116,6 +117,7 @@ import {
116117
MountPassive,
117118
} from './ReactHookEffectTags';
118119
import {didWarnAboutReassigningProps} from './ReactFiberBeginWork';
120+
import {runWithPriority, NormalPriority} from './SchedulerWithReactIntegration';
119121

120122
let didWarnAboutUndefinedSnapshotBeforeUpdate: Set<mixed> | null = null;
121123
if (__DEV__) {
@@ -716,7 +718,10 @@ function commitDetachRef(current: Fiber) {
716718
// User-originating errors (lifecycles and refs) should not interrupt
717719
// deletion, so don't let them throw. Host-originating errors should
718720
// interrupt deletion, so it's okay
719-
function commitUnmount(current: Fiber): void {
721+
function commitUnmount(
722+
current: Fiber,
723+
renderPriorityLevel: ReactPriorityLevel,
724+
): void {
720725
onCommitUnmount(current);
721726

722727
switch (current.tag) {
@@ -729,14 +734,33 @@ function commitUnmount(current: Fiber): void {
729734
const lastEffect = updateQueue.lastEffect;
730735
if (lastEffect !== null) {
731736
const firstEffect = lastEffect.next;
732-
let effect = firstEffect;
733-
do {
734-
const destroy = effect.destroy;
735-
if (destroy !== undefined) {
736-
safelyCallDestroy(current, destroy);
737-
}
738-
effect = effect.next;
739-
} while (effect !== firstEffect);
737+
738+
// When the owner fiber is deleted, the destroy function of a passive
739+
// effect hook is called during the synchronous commit phase. This is
740+
// a concession to implementation complexity. Calling it in the
741+
// passive effect phase (like they usually are, when dependencies
742+
// change during an update) would require either traversing the
743+
// children of the deleted fiber again, or including unmount effects
744+
// as part of the fiber effect list.
745+
//
746+
// Because this is during the sync commit phase, we need to change
747+
// the priority.
748+
//
749+
// TODO: Reconsider this implementation trade off.
750+
const priorityLevel =
751+
renderPriorityLevel > NormalPriority
752+
? NormalPriority
753+
: renderPriorityLevel;
754+
runWithPriority(priorityLevel, () => {
755+
let effect = firstEffect;
756+
do {
757+
const destroy = effect.destroy;
758+
if (destroy !== undefined) {
759+
safelyCallDestroy(current, destroy);
760+
}
761+
effect = effect.next;
762+
} while (effect !== firstEffect);
763+
});
740764
}
741765
}
742766
break;
@@ -777,7 +801,7 @@ function commitUnmount(current: Fiber): void {
777801
// We are also not using this parent because
778802
// the portal will get pushed immediately.
779803
if (supportsMutation) {
780-
unmountHostComponents(current);
804+
unmountHostComponents(current, renderPriorityLevel);
781805
} else if (supportsPersistence) {
782806
emptyPortalContainer(current);
783807
}
@@ -795,15 +819,18 @@ function commitUnmount(current: Fiber): void {
795819
}
796820
}
797821

798-
function commitNestedUnmounts(root: Fiber): void {
822+
function commitNestedUnmounts(
823+
root: Fiber,
824+
renderPriorityLevel: ReactPriorityLevel,
825+
): void {
799826
// While we're inside a removed host node we don't want to call
800827
// removeChild on the inner nodes because they're removed by the top
801828
// call anyway. We also want to call componentWillUnmount on all
802829
// composites before this host node is removed from the tree. Therefore
803830
// we do an inner loop while we're still inside the host node.
804831
let node: Fiber = root;
805832
while (true) {
806-
commitUnmount(node);
833+
commitUnmount(node, renderPriorityLevel);
807834
// Visit children because they may contain more composite or host nodes.
808835
// Skip portals because commitUnmount() currently visits them recursively.
809836
if (
@@ -1054,7 +1081,7 @@ function commitPlacement(finishedWork: Fiber): void {
10541081
}
10551082
}
10561083

1057-
function unmountHostComponents(current): void {
1084+
function unmountHostComponents(current, renderPriorityLevel): void {
10581085
// We only have the top Fiber that was deleted but we need to recurse down its
10591086
// children to find all the terminal nodes.
10601087
let node: Fiber = current;
@@ -1102,7 +1129,7 @@ function unmountHostComponents(current): void {
11021129
}
11031130

11041131
if (node.tag === HostComponent || node.tag === HostText) {
1105-
commitNestedUnmounts(node);
1132+
commitNestedUnmounts(node, renderPriorityLevel);
11061133
// After all the children have unmounted, it is now safe to remove the
11071134
// node from the tree.
11081135
if (currentParentIsContainer) {
@@ -1119,7 +1146,7 @@ function unmountHostComponents(current): void {
11191146
// Don't visit children because we already visited them.
11201147
} else if (node.tag === FundamentalComponent) {
11211148
const fundamentalNode = node.stateNode.instance;
1122-
commitNestedUnmounts(node);
1149+
commitNestedUnmounts(node, renderPriorityLevel);
11231150
// After all the children have unmounted, it is now safe to remove the
11241151
// node from the tree.
11251152
if (currentParentIsContainer) {
@@ -1161,7 +1188,7 @@ function unmountHostComponents(current): void {
11611188
continue;
11621189
}
11631190
} else {
1164-
commitUnmount(node);
1191+
commitUnmount(node, renderPriorityLevel);
11651192
// Visit children because we may find more host components below.
11661193
if (node.child !== null) {
11671194
node.child.return = node;
@@ -1188,14 +1215,17 @@ function unmountHostComponents(current): void {
11881215
}
11891216
}
11901217

1191-
function commitDeletion(current: Fiber): void {
1218+
function commitDeletion(
1219+
current: Fiber,
1220+
renderPriorityLevel: ReactPriorityLevel,
1221+
): void {
11921222
if (supportsMutation) {
11931223
// Recursively delete all host nodes from the parent.
11941224
// Detach refs and call componentWillUnmount() on the whole subtree.
1195-
unmountHostComponents(current);
1225+
unmountHostComponents(current, renderPriorityLevel);
11961226
} else {
11971227
// Detach refs and call componentWillUnmount() on the whole subtree.
1198-
commitNestedUnmounts(current);
1228+
commitNestedUnmounts(current, renderPriorityLevel);
11991229
}
12001230
detachFiber(current);
12011231
}

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1652,7 +1652,12 @@ function commitRootImpl(root, renderPriorityLevel) {
16521652
nextEffect = firstEffect;
16531653
do {
16541654
if (__DEV__) {
1655-
invokeGuardedCallback(null, commitMutationEffects, null);
1655+
invokeGuardedCallback(
1656+
null,
1657+
commitMutationEffects,
1658+
null,
1659+
renderPriorityLevel,
1660+
);
16561661
if (hasCaughtError()) {
16571662
invariant(nextEffect !== null, 'Should be working on an effect.');
16581663
const error = clearCaughtError();
@@ -1661,7 +1666,7 @@ function commitRootImpl(root, renderPriorityLevel) {
16611666
}
16621667
} else {
16631668
try {
1664-
commitMutationEffects();
1669+
commitMutationEffects(renderPriorityLevel);
16651670
} catch (error) {
16661671
invariant(nextEffect !== null, 'Should be working on an effect.');
16671672
captureCommitPhaseError(nextEffect, error);
@@ -1850,7 +1855,7 @@ function commitBeforeMutationEffects() {
18501855
}
18511856
}
18521857

1853-
function commitMutationEffects() {
1858+
function commitMutationEffects(renderPriorityLevel) {
18541859
// TODO: Should probably move the bulk of this function to commitWork.
18551860
while (nextEffect !== null) {
18561861
setCurrentDebugFiberInDEV(nextEffect);
@@ -1901,7 +1906,7 @@ function commitMutationEffects() {
19011906
break;
19021907
}
19031908
case Deletion: {
1904-
commitDeletion(nextEffect);
1909+
commitDeletion(nextEffect, renderPriorityLevel);
19051910
break;
19061911
}
19071912
}

packages/react-reconciler/src/__tests__/ReactSchedulerIntegration-test.internal.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,11 @@ describe('ReactSchedulerIntegration', () => {
149149
Scheduler.unstable_yieldValue(
150150
`Effect priority: ${getCurrentPriorityAsString()}`,
151151
);
152+
return () => {
153+
Scheduler.unstable_yieldValue(
154+
`Effect clean-up priority: ${getCurrentPriorityAsString()}`,
155+
);
156+
};
152157
});
153158
return null;
154159
}
@@ -170,6 +175,7 @@ describe('ReactSchedulerIntegration', () => {
170175
});
171176
expect(Scheduler).toHaveYielded([
172177
'Render priority: UserBlocking',
178+
'Effect clean-up priority: Normal',
173179
'Effect priority: Normal',
174180
]);
175181

@@ -181,6 +187,7 @@ describe('ReactSchedulerIntegration', () => {
181187
});
182188
expect(Scheduler).toHaveYielded([
183189
'Render priority: Idle',
190+
'Effect clean-up priority: Idle',
184191
'Effect priority: Idle',
185192
]);
186193
});
@@ -213,6 +220,51 @@ describe('ReactSchedulerIntegration', () => {
213220
]);
214221
});
215222

223+
it('passive effect clean-up functions have correct priority even when component is deleted', async () => {
224+
const {useEffect} = React;
225+
function ReadPriority({step}) {
226+
useEffect(() => {
227+
return () => {
228+
Scheduler.unstable_yieldValue(
229+
`Effect clean-up priority: ${getCurrentPriorityAsString()}`,
230+
);
231+
};
232+
});
233+
return null;
234+
}
235+
236+
await ReactNoop.act(async () => {
237+
ReactNoop.render(<ReadPriority />);
238+
});
239+
await ReactNoop.act(async () => {
240+
Scheduler.unstable_runWithPriority(ImmediatePriority, () => {
241+
ReactNoop.render(null);
242+
});
243+
});
244+
expect(Scheduler).toHaveYielded(['Effect clean-up priority: Normal']);
245+
246+
await ReactNoop.act(async () => {
247+
ReactNoop.render(<ReadPriority />);
248+
});
249+
await ReactNoop.act(async () => {
250+
Scheduler.unstable_runWithPriority(UserBlockingPriority, () => {
251+
ReactNoop.render(null);
252+
});
253+
});
254+
expect(Scheduler).toHaveYielded(['Effect clean-up priority: Normal']);
255+
256+
// Renders lower than normal priority spawn effects at the same priority
257+
await ReactNoop.act(async () => {
258+
ReactNoop.render(<ReadPriority />);
259+
});
260+
await ReactNoop.act(async () => {
261+
Scheduler.unstable_runWithPriority(IdlePriority, () => {
262+
ReactNoop.render(null);
263+
});
264+
});
265+
expect(Scheduler).toHaveYielded(['Effect clean-up priority: Idle']);
266+
});
267+
216268
it('after completing a level of work, infers priority of the next batch based on its expiration time', () => {
217269
function App({label}) {
218270
Scheduler.unstable_yieldValue(

0 commit comments

Comments
 (0)