Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Merging client and server trees

Adding Server Component support to DevTools is going to be a big lift, but generally speaking there are a few initial things to work out:

  • Updating the Flight server renderer to stream server component names/types to the client.
  • Adding a new hook to DevTools for the Flight client to call.
  • Merging the server component subtrees into the DevTools client tree.

Yesterday and today I've been thinking about this last item, and I'm feeling pretty stumped at the moment. Merging the trees isn't that difficult, but preserving the tree across client updates gets nasty when it comes to things like conditionally rendered elements.

Setting performance concerns aside, even if I were to undo the merged trees, apply the client updates, and then redo the merge– I'm still not sure we would definitely end up with the correct final state.

For example, consider the following client component that accepts "children" rendered by a server component:

function StatefulClientComponent({ children = null }) {
  // State...
  return (
    <React.Fragment>
      {condition && <ClientComponent key="A" />}
      {children}
      {condition && <ClientComponent key="B" />}
    </React.Fragment>
  );
}

First, DevTools would see this tree as only:

<StatefulClientComponent>

Then server component info could be added in, e.g.

<StatefulClientComponent>
  <ServerComponent>

If condition were to be true, DevTools should update its (merged) tree to:

<StatefulClientComponent>
  <ClientComponent key="A">
  <ServerComponent>
  <ClientComponent key="B">

But I'm not sure how it should know that "A" goes before ServerComponent and "C" goes after.

The way DevTools appends children further complicates things: First it sends "create" commands for new children, then it sends a "reorder" command (consisting of only the child ids). In this case, that means that the reorder command would only specify "A" and "B" (and DevTools would need to know to leave the ServerComponent in place).

Maybe that's not so difficult to do in this simple example, but what if the server component rendered content (passed as children) contained nested client components? Then the initial DevTools tree might look something like this:

<StatefulClientComponent>
  <NestedClientComponent>

And the merged tree would look like this:

<StatefulClientComponent>
  <ServerComponent>
    <NestedClientComponent>

Now when there's an update, DevTools would tell the frontend that StatefulClientComponent has 3 children: "A", NestedClientComponent, and "B". So DevTools would need to both ignore children that had previously been reparented and ignore server components when re-ordering.

@bvaughn

This comment has been minimized.

Copy link
Owner Author

@bvaughn bvaughn commented Jun 5, 2021

Rather than merging the two trees, maybe the thing to do here is to store a separate, parallel data structure. For example, given the following app code:

<ClientA>
  <ServerA>
    <ClientB>

DevTools would generate the following (client) tree initially:

<ClientA>
  <ClientB>

We could merge the two easily, but then if conditional children were rendered such that the app became:

<ClientA>
  <ClientC>
  <ServerA>
    <ClientB>
  <ClientD>

Then it would be difficult to apply this update to the already-merged tree. But, if instead of merging the trees, we stored separate metadata that e.g. the edge between client components ClientA and ClientB should be replaced overridden with an edge between ClientA and ServerA, and we updated the index-to-tree generation logic to account for this metadata as well, then the update could be applied to the original client tree without interfering with the merge.

There may be some trickiness in terms of updating the weight of nodes in the tree, mapping element-to-index, removing children, etc. but I think this direction may be more promising.

image

@bvaughn

This comment has been minimized.

Copy link
Owner Author

@bvaughn bvaughn commented Jun 5, 2021

I think the direction above is promising, assuming it actually handles the update cases like I think it can, but there's a secondary level of things I haven't yet thought about how to handle: the children and parent pointers for each element. The Store's tree state can use the edge overrides, but individual elements will still point to children and parents without the overrides. Seems like this will cause problems.

Spitballing here, but maybe I could make these pointers "private" attributes, and redirect all reading of them through public methods on the Store (where the edges map could be checked) but this feels very heavy handed.

@bvaughn

This comment has been minimized.

Copy link
Owner Author

@bvaughn bvaughn commented Jun 5, 2021

I think there's a case the edge-override approach doesn't handle: Server component leafs.

Like this:

<ClientA>
  <ServerA>

Or this:

<ClientA>
  <ServerA>
  <ClientB>
  <ServerB>

There are no edges to override in this case. 🙁

@bvaughn

This comment has been minimized.

Copy link
Owner Author

@bvaughn bvaughn commented Jun 5, 2021

But I'm not sure how it should know that "A" goes before ServerComponent and "C" goes after.

I guess DevTools could match up the re-ordered trees by recursing the server-component subtrees to find a match.

For instance, let's say we start with the following tree:

<Client A>
  <Client B>
  <Server A>
    <Client C>
  <Server B>

And the Client swaps the B and C branches so we end up with:

<Client A>
  <Server A>
    <Client C>
  <Client B>
  <Server B>

Even though the backend would send a message saying "Client A's children should be Client C and Client B now", I think DevTools could technically do the update correctly if it searched inside of Sever A and Server B and determined that Client C was inside of Server A.

That being said, this seems expensive for something that happens often (b'c of how DevTools works when it appends new elements– mentioned in the original post). We'd end up recursing fully down any branch that didn't contain a match.

Or would we? Maybe instead of storing overrides from a client edge to a server node, we could store metadata for each added sever edge (Client A -> Server A and Client B -> Server B) that points to a list of the "reparented" client nodes? Then the algorithm to scan server subtrees to find a re-ordered client node would not need to recurse.

@bvaughn

This comment has been minimized.

Copy link
Owner Author

@bvaughn bvaughn commented Jun 5, 2021

Thinking outside of the box, what if React were to create Fibers for server components– a new type of Fiber that worked as a pass-through like Fragment or StrictMode? It could only do this when DevTools was present (isDevToolsPresent) to avoid adding memory unnecessarily. This would make DevTools integration much easier.

@bvaughn

This comment has been minimized.

Copy link
Owner Author

@bvaughn bvaughn commented Jun 7, 2021

Another dimension of this that we haven't even considered yet is component filtering.

If Server Components are just a new/special Fiber type then component filtering "just works".

If they're a separate thing that DevTools needs to merge in, then it has to also filter them (which could make the merge difficult), and preserve the metadata somewhere so that changing component filters doesn't break the tree.

As a side note, should we add a new filter-type for Server vs Client components?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment