Skip to content

Commit

Permalink
[DevTools] Support Server Components in Tree (#30684)
Browse files Browse the repository at this point in the history
This adds VirtualInstances to the tree. Each Fiber has a list of its
parent Server Components in `_debugInfo`. The algorithm is that when we
enter a set of fibers, we actually traverse level 0 of all the
`_debugInfo` in each fiber. Then level 1 of each `_debugInfo` and so on.
It would be simpler if `_debugInfo` only contained Server Component
since then we could just look at the index in the array but it actually
contains other data as well which leads to multiple passes but we don't
expect it to have a lot of levels before hitting a reified fiber.
Finally when we hit the end a traverse the fiber itself.

This lets us match consecutive `ReactComponentInfo` that are all the
same at the same level. This creates a single VirtualInstance for each
sequence. This lets the same Server Component instance that's a parent
to multiple children appear as a single Instance instead of one per
Fiber.

Since a Server Component's result can be rendered in more than one place
there's not a 1:1 mapping though. If it is in different parents or if
the sequence is interrupted, then it gets split into two different
instances with the same `ReactComponentInfo` data.

The real interesting case is what happens during updates because this
algorithm means that a Fiber can become reparented during an update to
end up in a different VirtualInstance. The ideal would maybe be that the
frontend could deal with this reparenting but instead I basically just
unmount the previous instance (and its children) and mount a new
instance which leads to some interesting scenarios. This is inline with
the strategy I was intending to pursue anyway where instances are
reconciled against the previous children of the same parent instead of
the `fiberToFiberInstance` map - which would let us get rid of that map.
In that case the model is resilient to Fiber being in more than one
place at a time.

However this unmount/remount does mean that we can lose selection when
this happens. We could maybe do something like using the tracked path
like I did for component filters. Ideally it's a weird edge case though
because you'd typically not have it. The main case that it happens now
is for reorders of list of server components. In that case basically all
the children move between server components while the server components
themselves stay in place. We should really include the key in server
components so that we can reconcile them using the key to handle
reorders which would solve the common case anyway.

I convert the name to the `Env(Name)` pattern which allows the
Environment Name to be used as a badge.

<img width="1105" alt="Screenshot 2024-08-13 at 9 55 29 PM"
src="https:/user-attachments/assets/323c20ba-b655-4ee8-84fa-8233f55d2999">

(Screenshot is with #30667. I haven't tried it with the alternative
fix.)

---------

Co-authored-by: Ruslan Lesiutin <[email protected]>
  • Loading branch information
sebmarkbage and hoxyq authored Aug 14, 2024
1 parent 0ad0fac commit 49496d4
Show file tree
Hide file tree
Showing 5 changed files with 912 additions and 86 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3150,4 +3150,35 @@ describe('InspectedElement', () => {
<Child> ⚠
`);
});

// @reactVersion > 18.2
it('should inspect server components', async () => {
const ChildPromise = Promise.resolve(<div />);
ChildPromise._debugInfo = [
{
name: 'ServerComponent',
env: 'Server',
owner: null,
},
];
const Parent = () => ChildPromise;

await utils.actAsync(() => {
modernRender(<Parent />);
});

const inspectedElement = await inspectElementAtIndex(1);
expect(inspectedElement).toMatchInlineSnapshot(`
{
"context": null,
"events": undefined,
"hooks": null,
"id": 3,
"owners": null,
"props": null,
"rootType": "createRoot()",
"state": null,
}
`);
});
});
276 changes: 276 additions & 0 deletions packages/react-devtools-shared/src/__tests__/store-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2212,4 +2212,280 @@ describe('Store', () => {
`);
});
});

// @reactVersion > 18.2
it('does not show server components without any children reified children', async () => {
// A Server Component that doesn't render into anything on the client doesn't show up.
const ServerPromise = Promise.resolve(null);
ServerPromise._debugInfo = [
{
name: 'ServerComponent',
env: 'Server',
owner: null,
},
];
const App = () => ServerPromise;

await actAsync(() => render(<App />));
expect(store).toMatchInlineSnapshot(`
[root]
<App>
`);
});

// @reactVersion > 18.2
it('does show a server component that renders into a filtered node', async () => {
const ServerPromise = Promise.resolve(<div />);
ServerPromise._debugInfo = [
{
name: 'ServerComponent',
env: 'Server',
owner: null,
},
];
const App = () => ServerPromise;

await actAsync(() => render(<App />));
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
<ServerComponent> [Server]
`);
});

it('can render the same server component twice', async () => {
function ClientComponent() {
return <div />;
}
const ServerPromise = Promise.resolve(<ClientComponent />);
ServerPromise._debugInfo = [
{
name: 'ServerComponent',
env: 'Server',
owner: null,
},
];
const App = () => (
<>
{ServerPromise}
<ClientComponent />
{ServerPromise}
</>
);

await actAsync(() => render(<App />));
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <ServerComponent> [Server]
<ClientComponent>
<ClientComponent>
▾ <ServerComponent> [Server]
<ClientComponent>
`);
});

// @reactVersion > 18.2
it('collapses multiple parent server components into one', async () => {
function ClientComponent() {
return <div />;
}
const ServerPromise = Promise.resolve(<ClientComponent />);
ServerPromise._debugInfo = [
{
name: 'ServerComponent',
env: 'Server',
owner: null,
},
];
const ServerPromise2 = Promise.resolve(<ClientComponent />);
ServerPromise2._debugInfo = [
{
name: 'ServerComponent2',
env: 'Server',
owner: null,
},
];
const App = ({initial}) => (
<>
{ServerPromise}
{ServerPromise}
{ServerPromise2}
{initial ? null : ServerPromise2}
</>
);

await actAsync(() => render(<App initial={true} />));
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <ServerComponent> [Server]
<ClientComponent>
<ClientComponent>
▾ <ServerComponent2> [Server]
<ClientComponent>
`);

await actAsync(() => render(<App initial={false} />));
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <ServerComponent> [Server]
<ClientComponent>
<ClientComponent>
▾ <ServerComponent2> [Server]
<ClientComponent>
<ClientComponent>
`);
});

// @reactVersion > 18.2
it('can reparent a child when the server components change', async () => {
function ClientComponent() {
return <div />;
}
const ServerPromise = Promise.resolve(<ClientComponent />);
ServerPromise._debugInfo = [
{
name: 'ServerAB',
env: 'Server',
owner: null,
},
];
const ServerPromise2 = Promise.resolve(<ClientComponent />);
ServerPromise2._debugInfo = [
{
name: 'ServerA',
env: 'Server',
owner: null,
},
{
name: 'ServerB',
env: 'Server',
owner: null,
},
];
const App = ({initial}) => (initial ? ServerPromise : ServerPromise2);

await actAsync(() => render(<App initial={true} />));
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <ServerAB> [Server]
<ClientComponent>
`);

await actAsync(() => render(<App initial={false} />));
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <ServerA> [Server]
▾ <ServerB> [Server]
<ClientComponent>
`);
});

// @reactVersion > 18.2
it('splits a server component parent when a different child appears between', async () => {
function ClientComponent() {
return <div />;
}
const ServerPromise = Promise.resolve(<ClientComponent />);
ServerPromise._debugInfo = [
{
name: 'ServerComponent',
env: 'Server',
owner: null,
},
];
const App = ({initial}) =>
initial ? (
<>
{ServerPromise}
{null}
{ServerPromise}
</>
) : (
<>
{ServerPromise}
<ClientComponent />
{ServerPromise}
</>
);

await actAsync(() => render(<App initial={true} />));
// Initially the Server Component only appears once because the children
// are consecutive.
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <ServerComponent> [Server]
<ClientComponent>
<ClientComponent>
`);

// Later the same instance gets split into two when it is no longer
// consecutive so we need two virtual instances to represent two parents.
await actAsync(() => render(<App initial={false} />));
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <ServerComponent> [Server]
<ClientComponent>
<ClientComponent>
▾ <ServerComponent> [Server]
<ClientComponent>
`);
});

// @reactVersion > 18.2
it('can reorder keyed components', async () => {
function ClientComponent({text}) {
return <div>{text}</div>;
}
function getServerComponent(key) {
const ServerPromise = Promise.resolve(
<ClientComponent key={key} text={key} />,
);
ServerPromise._debugInfo = [
{
name: 'ServerComponent',
env: 'Server',
owner: null,
// TODO: Ideally the debug info should include the "key" too to
// preserve the virtual identity of the server component when
// reordered. Atm only the children of it gets reparented.
},
];
return ServerPromise;
}
const set1 = ['A', 'B', 'C'].map(getServerComponent);
const set2 = ['B', 'A', 'D'].map(getServerComponent);

const App = ({initial}) => (initial ? set1 : set2);

await actAsync(() => render(<App initial={true} />));
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <ServerComponent> [Server]
<ClientComponent key="A">
▾ <ServerComponent> [Server]
<ClientComponent key="B">
▾ <ServerComponent> [Server]
<ClientComponent key="C">
`);

await actAsync(() => render(<App initial={false} />));
expect(store).toMatchInlineSnapshot(`
[root]
▾ <App>
▾ <ServerComponent> [Server]
<ClientComponent key="B">
▾ <ServerComponent> [Server]
<ClientComponent key="A">
▾ <ServerComponent> [Server]
<ClientComponent key="D">
`);
});
});
Loading

0 comments on commit 49496d4

Please sign in to comment.