Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions packages/react-devtools-shared/src/__tests__/store-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,78 @@ describe('Store', () => {
expect(leaf.displayName).toBe('Leaf');
expect(leaf.isInsideHiddenActivity).toBe(true);
});

// Regression test for https://github.com/facebook/react/issues/36315
// and https://github.com/facebook/react/issues/36375.
// @reactVersion >= 19
it('should not throw when a child of hidden Activity is removed while a parent Suspense is suspended', async () => {
const Activity = React.Activity || React.unstable_Activity;

let resolve;
const never = new Promise(_resolve => {
resolve = _resolve;
});

function Suspender() {
readValue(never);
return null;
}

function Child() {
return <div>child</div>;
}

function App({showChild, suspend}) {
return (
<React.Suspense fallback={<div>Loading</div>}>
{suspend ? <Suspender /> : null}
<Activity mode="hidden">{showChild ? <Child /> : null}</Activity>
</React.Suspense>
);
}

// Mount with child visible inside hidden Activity (child is in the Store)
await actAsync(() => {
render(<App showChild={true} suspend={false} />);
});
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <Suspense>
▸ <Activity mode="hidden">
[suspense-root] rects={[{x:1,y:2,width:5,height:1}]}
<Suspense name="App" uniqueSuspenders={false} rects={[{x:1,y:2,width:5,height:1}]}>
`);

// Suspend the Suspense — this sends REMOVE for Activity and Child
await actAsync(() => {
render(<App showChild={true} suspend={true} />);
});

// Remove Child from Activity's content while Suspense is still suspended.
// This must NOT send a second REMOVE for Child (which is no longer in the Store).
await actAsync(() => {
render(<App showChild={false} suspend={true} />);
});

// Reveal Suspense — Activity and any remaining children should be re-added
await actAsync(() => {
render(<App showChild={false} suspend={false} />);
});
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <Suspense>
<Activity mode="hidden">
[suspense-root] rects={null}
<Suspense name="App" uniqueSuspenders={true} rects={null}>
`);

// Resolve the promise to clean up
await actAsync(() => {
resolve();
});
});
});

describe('collapseNodesByDefault:false', () => {
Expand Down
18 changes: 18 additions & 0 deletions packages/react-devtools-shared/src/backend/fiber/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ function createFiberInstance(fiber: Fiber): FiberInstance {
treeBaseDuration: 0,
suspendedBy: null,
suspenseNode: null,
isDisconnected: false,
data: fiber,
};
}
Expand Down Expand Up @@ -244,6 +245,7 @@ function createVirtualInstance(
treeBaseDuration: 0,
suspendedBy: null,
suspenseNode: null,
isDisconnected: false,
data: debugEntry,
};
}
Expand Down Expand Up @@ -1784,6 +1786,7 @@ export function attach(
// We're disconnected. We'll reconnect a hidden mount after the parent reappears.
return;
}
fiberInstance.isDisconnected = false;
const id = fiberInstance.id;
const fiber = fiberInstance.data;

Expand Down Expand Up @@ -1968,6 +1971,7 @@ export function attach(
// We're disconnected. We'll reconnect a hidden mount after the parent reappears.
return;
}
instance.isDisconnected = false;
const componentInfo = instance.data;

const key =
Expand Down Expand Up @@ -2128,6 +2132,13 @@ export function attach(
return;
}

if (fiberInstance.isDisconnected) {
// A REMOVE was already sent for this instance (e.g. via disconnectChildrenRecursively
// when a parent Suspense suspended). Don't send a second REMOVE.
return;
}
fiberInstance.isDisconnected = true;

if (trackedPathMatchInstance === fiberInstance) {
// We're in the process of trying to restore previous selection.
// If this fiber matched but is being hidden, there's no use trying.
Expand Down Expand Up @@ -2853,6 +2864,13 @@ export function attach(
if (isInDisconnectedSubtree) {
return;
}

if (instance.isDisconnected) {
// A REMOVE was already sent for this instance. Don't send a second REMOVE.
return;
}
instance.isDisconnected = true;

if (trackedPathMatchInstance === instance) {
// We're in the process of trying to restore previous selection.
// If this fiber matched but is being unmounted, there's no use trying.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export type FiberInstance = {
treeBaseDuration: number, // the profiled time of the last render of this subtree
suspendedBy: null | Array<ReactAsyncInfo>, // things that suspended in the children position of this component
suspenseNode: null | SuspenseNode,
isDisconnected: boolean, // true if a REMOVE was sent to the frontend for this instance
data: Fiber, // one of a Fiber pair
};

Expand Down Expand Up @@ -69,6 +70,7 @@ export type VirtualInstance = {
treeBaseDuration: number, // the profiled time of the last render of this subtree
suspendedBy: null | Array<ReactAsyncInfo>, // things that blocked the server component's child from rendering
suspenseNode: null,
isDisconnected: boolean, // true if a REMOVE was sent to the frontend for this instance
// The latest info for this instance. This can be updated over time and the
// same info can appear in more than once ServerComponentInstance.
data: ReactComponentInfo,
Expand Down