-
Notifications
You must be signed in to change notification settings - Fork 47k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Flush useEffect
clean up functions in the passive effects phase
#17925
Flush useEffect
clean up functions in the passive effects phase
#17925
Conversation
} | ||
effect = effect.next; | ||
} while (effect !== firstEffect); | ||
}); | ||
} else { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the old code path, in case we enable the kill switch.
// Note: We currently never use MountMutation, but useLayoutEffect uses UnmountMutation. | ||
// This is to ensure ALL destroy fns are run before create fns, | ||
// without requiring us to traverse the effects list an extra time during commit. | ||
// This sequence prevents sibling destroy and create fns from interfering with each other. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added these comments for myself, since it wasn't clear to me why we were using these tags.
export const MountLayout = /* */ 0b000100000; | ||
export const MountPassive = /* */ 0b001000000; | ||
export const UnmountPassive = /* */ 0b010000000; | ||
export const NoEffectPassiveUnmountFiber = /**/ 0b100000000; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The name of the new effect tag I added, NoEffectPassiveUnmountFiber
, is kind of stupid. I'd welcome a solution for a better name. 🤪
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it should be possible to simplify these tags by removing the Mount/Unmount . I think that's already handled because commitHookEffectList
accepts two separate arguments for which tags to mount and which tags to unmount.
Would it work if the only tags we had were this?
// Represents whether effect should fire.
export const HasEffect = /* */ 0b0001;
// Represents the phase in which the effect (not the clean-up) fires.
export const Snapshot = /* */ 0b0010;
export const Layout = /* */ 0b0100;
export const Passive = /* */ 0b1000;
So during an update you would fire the clean up of any hook that has (for passive effects) that has Passive | HasEffect
. When the fiber is deleted, you would only check Passive
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this cleanup suggestion makes sense, although while looking into it I realized we have a potential bug with the sequence/timing of passive effects. (We don't call all destroy functions before create functions.) I'll take a pass at the effect tag cleanup and separating the passive effect destroy/create phases too. If it starts looking like that might be a bigger change though, I may split it out into a follow up PR (#17945)
bf7b3c3
to
50f1706
Compare
function updateEffectImpl( | ||
fiberEffectTag, | ||
hookEffectTag, | ||
noWorkUnmountFiberHookEffectTag, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This name isn't the greatest either. I welcome better suggestions.
The purpose of the changes to this function is to retain the ability to distinguish between layout and passive effects even if a render has no dependency changes. Previously, we would not be able to distinguish between the two during unmount.
This pull request is automatically built and testable in CodeSandbox. To see build info of the built libraries, click here or the icon next to each commit SHA. Latest deployment of this branch, based on commit bf7b3c3:
|
This pull request is automatically built and testable in CodeSandbox. To see build info of the built libraries, click here or the icon next to each commit SHA. Latest deployment of this branch, based on commit 606af56:
|
Details of bundled changes.Comparing: 434770c...606af56 react
react-dom
react-test-renderer
react-native-renderer
react-art
react-reconciler
scheduler
ReactDOM: size: 0.0%, gzip: -0.0% Size changes (experimental) |
Details of bundled changes.Comparing: 434770c...606af56 react
react-dom
react-native-renderer
react-reconciler
react-art
scheduler
react-test-renderer
ReactDOM: size: 🔺+0.1%, gzip: -0.0% Size changes (stable) |
// HACK | ||
// This update is kind of gross since it exists to test an internal implementation detail: | ||
// Effects without updating dependencies lose their layout/passive tag during an update. | ||
// A special type of no-update tag (NoEffectPassiveUnmountFiber) is used to track these for later. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To make it less "hacky" you could include the count in the log message. So that the effect actually depends on it.
Then make the other effect close over a different prop. count2
or whatever.
Then during the update, you change one of the props but not the other. Only that effect should fire; the other one will bail out.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, sure. That"hacky" part is that I'm only doing this intermediate update because I know that used to cause the specific implementation to blur the passive and layout effects together.
if (deferPassiveEffectCleanupDuringUnmount) { | ||
pendingUnmountedPassiveEffectDestroyFunctions.push(destroy); | ||
rootDoesHavePassiveEffects = true; | ||
scheduleCallback(NormalPriority, () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will schedule a callback for every clean-up effect. Need to check if rootDoesHavePassiveEffects
is already true.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, good call!
50f1706
to
d140ab0
Compare
d140ab0
to
1c6be34
Compare
@@ -250,7 +248,6 @@ function commitBeforeMutationLifeCycles( | |||
case ForwardRef: | |||
case SimpleMemoComponent: | |||
case Chunk: { | |||
commitHookEffectList(UnmountSnapshot, NoHookEffect, finishedWork); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't have a snapshot phase hook (yet) so this call was walking the effect list for no reason. I've removed it.
i++ | ||
) { | ||
const destroy = pendingUnmountedPassiveEffectDestroyFunctions[i]; | ||
invokeGuardedCallback(null, destroy, null); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This loop approach has an unintentional side effect of being "better" than our normal destroy approach, since an error thrown by one destroy function won't prevent other destroy functions on the same Fiber from being called.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did you confirm that with a test? I would expect the current behavior to also unmount all the destroy functions on a fiber, even if one throws, because if one of them throws, then the nearest error boundary fiber gets deleted, which triggers commitNestedUnmounts
, which then calls the remaining clean-up functions and wraps each call in a try catch:
safelyCallDestroy(current, destroy); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, yes I think you are right. Disregard!
packages/shared/ReactFeatureFlags.js
Outdated
// Previously these functions were run during commit (along with layout effects). | ||
// Ideally we should delay these until after commit for performance reasons. | ||
// This flag provides a killswitch if that proves to break existing code somehow. | ||
export const deferPassiveEffectCleanupDuringUnmount = true; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Open question: should I set this to false
for now (for open source releases)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah probably, just to be safe
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
K, will do then.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Left some nits. Looks good to me!
if ((effect.tag & mountTag) !== NoHookEffect) { | ||
if ( | ||
(effect.tag & HookHasEffect) !== NoHookEffect && | ||
(effect.tag & mountTag) !== NoHookEffect |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: You can save a check here if you assume mountTag
and unmountTag
already include HookHasEffect
:
(effect.tag & mountTag) === mountTag
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm! But they specifically don't.
We use the tag to specify the type of effect even if there's no work currently required by the hook. This lets us properly differentiate between passive and layout effects in the unmount case. (NoHookEffect
is used to signal that there's also work for that specific hook during the current commit.)
// before calling any create functions. The current approach only serializes | ||
// these for a single fiber. | ||
commitHookEffectList(HookPassive, NoHookEffect, finishedWork); | ||
commitHookEffectList(NoHookEffect, HookPassive, finishedWork); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Following from previous comment, here you would combine the tag with HookHasEffect
before passing them in:
commitHookEffectList(HookPassive | HookHasEffect, NoHookEffect, finishedWork);
commitHookEffectList(NoHookEffect, HookPassive | HookHasEffect, finishedWork);
(I assume Closure inlines those values, or you could also hoist them out to module scope)
This is a change in behavior that may cause broken product code, so it has been added behind a killswitch (deferPassiveEffectCleanupDuringUnmount)
Updated enqueuePendingPassiveEffectDestroyFn() to check rootDoesHavePassiveEffects before scheduling a new callback. This way we'll only schedule (at most) one.
We previously used separate Mount* and Unmount* tags to track hooks work for each phase (snapshot, mutation, layout, and passive). This was somewhat complicated to trace through and there were man tag types we never even used (e.g. UnmountLayout, MountMutation, UnmountSnapshot). In addition to this, it left passive and layout hooks looking the same after renders without changed dependencies, which meant we were unable to reliably defer passive effect destroy functions until after the commit phase. This commit reduces the effect tag types to only include Layout and Passive and differentiates between work and no-work with an HasEffect flag.
* develop: add badge to UserMenu when update is available migrate all links to WIKI_LINKS add UpdateChecker default signInOptions to google if signInOptions doc is missing fix TableSettings multiSelects TextEditor: fix values not saving in react 17 (thanks @oliviertassinari ) mui/material-ui#25560 (comment) facebook/react#17925 expose dataset location option for BQ spark
This PR cleans up our hook effect tags and also fixes the case where we were too eagerly flushing passive effect destroy functions on unmount. (As a side effect, we are also now reliably able to distinguish between passive and layout effects during unmount, which will improve the new Profiler methods added in #17910.)
The two major changes in this PR are:
This is a change in behavior/timing for destroy functions so it may cause code to break unexpectedly. For this reason, it has been added behind a killswitch feature flag (
deferPassiveEffectCleanupDuringUnmount
).I've also identified some follow up work for passive effects, but to keep this PR small and easy to review, I've just added a TODO for this and will be tracking it with a separate issue, #17945.