Skip to content
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

Fix Ref Lifecycles in Hidden Subtrees #31379

Merged
merged 10 commits into from
Oct 31, 2024
20 changes: 12 additions & 8 deletions packages/react-reconciler/src/ReactFiberCommitWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -1325,7 +1325,9 @@ function commitDeletionEffectsOnFiber(
}
case ScopeComponent: {
if (enableScopeAPI) {
safelyDetachRef(deletedFiber, nearestMountedAncestor);
if (!offscreenSubtreeWasHidden) {
safelyDetachRef(deletedFiber, nearestMountedAncestor);
}
}
recursivelyTraverseDeletionEffects(
finishedRoot,
Expand All @@ -1335,7 +1337,9 @@ function commitDeletionEffectsOnFiber(
return;
}
case OffscreenComponent: {
safelyDetachRef(deletedFiber, nearestMountedAncestor);
if (!offscreenSubtreeWasHidden) {
safelyDetachRef(deletedFiber, nearestMountedAncestor);
}
Comment on lines +1340 to +1342
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the unit test for "ignores ref for Activity in hidden subtree", I discovered that this and the call from commitMutationEffectsOnFiber are triggered, causing X to be incorrectly invoked twice! 🤯

<Activity mode="hidden">
  <Activity mode="visible" ref={X}>
    <div />
  </Activity>
</Activity>

if (disableLegacyMode || deletedFiber.mode & ConcurrentMode) {
// If this offscreen component is hidden, we already unmounted it. Before
// deleting the children, track that it's already unmounted so that we
Expand Down Expand Up @@ -1572,7 +1576,7 @@ function recursivelyTraverseMutationEffects(
lanes: Lanes,
) {
// Deletions effects can be scheduled on any fiber type. They need to happen
// before the children effects hae fired.
// before the children effects have fired.
const deletions = parentFiber.deletions;
if (deletions !== null) {
for (let i = 0; i < deletions.length; i++) {
Expand Down Expand Up @@ -1636,7 +1640,7 @@ function commitMutationEffectsOnFiber(
recursivelyTraverseMutationEffects(root, finishedWork, lanes);
commitReconciliationEffects(finishedWork);

if (flags & Ref) {
if (flags & Ref && !offscreenSubtreeWasHidden) {
if (current !== null) {
safelyDetachRef(current, current.return);
}
Expand All @@ -1659,7 +1663,7 @@ function commitMutationEffectsOnFiber(
recursivelyTraverseMutationEffects(root, finishedWork, lanes);
commitReconciliationEffects(finishedWork);

if (flags & Ref) {
if (flags & Ref && !offscreenSubtreeWasHidden) {
if (current !== null) {
safelyDetachRef(current, current.return);
}
Expand Down Expand Up @@ -1744,7 +1748,7 @@ function commitMutationEffectsOnFiber(
recursivelyTraverseMutationEffects(root, finishedWork, lanes);
commitReconciliationEffects(finishedWork);

if (flags & Ref) {
if (flags & Ref && !offscreenSubtreeWasHidden) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the Jest unit test that @rickhanlonii added in a2c9e7a, this is the only line that is actually necessary to fix the unit test.

if (current !== null) {
safelyDetachRef(current, current.return);
}
Expand Down Expand Up @@ -1960,7 +1964,7 @@ function commitMutationEffectsOnFiber(
break;
}
case OffscreenComponent: {
if (flags & Ref) {
if (flags & Ref && !offscreenSubtreeWasHidden) {
if (current !== null) {
safelyDetachRef(current, current.return);
}
Expand Down Expand Up @@ -2073,7 +2077,7 @@ function commitMutationEffectsOnFiber(

// TODO: This is a temporary solution that allowed us to transition away
// from React Flare on www.
if (flags & Ref) {
if (flags & Ref && !offscreenSubtreeWasHidden) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't seem clear to me if the safelyAttachRef call should be done conditional here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conceptually, it makes sense to skip both. I'm not sure why Scope refs are even being attached here in the first place. 😕

if (current !== null) {
safelyDetachRef(finishedWork, finishedWork.return);
}
Expand Down
46 changes: 46 additions & 0 deletions packages/react-refresh/src/__tests__/ReactFreshIntegration-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,52 @@ describe('ReactFreshIntegration', () => {
}
});

// @gate enableActivity
it('does not re-create refs for Activity hidden', async () => {
if (__DEV__) {
await render(`
import {unstable_Activity as Activity} from 'react';

export default function App() {
return (
<Activity mode="hidden">
<div ref={(node) => {
if (node === null) {
throw new Error('callback ref should never be null')
}
return () => {
// ignore
}
}}>A</div>
</Activity>
);
};
`);
const el = container.firstChild;
expect(el.textContent).toBe('A');
await patch(`
import {unstable_Activity as Activity} from 'react';

export default function App() {
return (
<Activity mode="hidden">
<div ref={(node) => {
if (node === null) {
throw new Error('callback ref should never be null.')
}
return () => {
// ignore
}
}}>A</div>
</Activity>
);
};
`);
expect(container.firstChild).toBe(el);
expect(el.textContent).toBe('A');
}
});

it('reloads HOCs if they return functions', async () => {
if (__DEV__) {
await render(`
Expand Down
Loading