Skip to content
This repository has been archived by the owner on Jul 28, 2023. It is now read-only.

Children slots in client components #62

Closed
luisherranz opened this issue Aug 31, 2022 · 7 comments
Closed

Children slots in client components #62

luisherranz opened this issue Aug 31, 2022 · 7 comments

Comments

@luisherranz
Copy link
Member

luisherranz commented Aug 31, 2022

Moved from #60.

EDIT: Although this issue started as a way to figure out how to identify children in islands and client components, let's use this use to figure out how to fully hydrate client components, including nested components, multiple slots and out-of-order hydration.


Let's analyze what options we have to identify children:

  1. Use HTML comments, like <!-- slot --><div>...</div><!-- slot -->
  2. Use a wrapper, like <slot>
  3. Use an attribute, like <div wp-children>
  4. Figure out by comparing the DOM with the component output

1. Use HTML comments

<h1>Some title</h1>
<h3>Some description</h3>
<!-- slot -->
<div>I'm a child content</div>
<img src="with-an-image.png" />
<!-- slot -->

Many WordPress cache and optimization plugins and CDNs (including the popular Cloudflare) remove the HTML comments, so I'd try to avoid this option.

As reference, Fresh is using comments to find its islands, although it's a bit weird because they don't hydrate them and they don't support children.

2. Use a wrapper

A wrapper is reliable, but as we've seen with wp-block, display: contents is not the holy grail and they need to be taken into account because they affect CSS.

For interactive blocks, we could use the same <wp-inner-blocks> approach.

For client components (smaller than blocks), we could abstract it in the creation of the component, but the SSR should add it. Imagine this client component:

const WpHero = ({ title, description, children }) => (
  <>
    <h1>{title}</h1>
    <h3>{description}</h3>
    {children}
  </>
);

The SSR should be:

<wp-hero title="Some title" description="Some description">
  <h1>Some title</h1>
  <h3>Some description</h3>
  <slot name="children">
    <div>I'm a child content</div>
    <img src="with-an-image.png" />
  </slot>
</wp-hero>

So maybe we should not abstract it in the JSX, and make it explicit:

const WpHero = ({ title, description, children }) => (
  <>
    <h1>{title}</h1>
    <h3>{description}</h3>
    <slot name="children">{children}</slot>
  </>
);

We could add slot[name=children] { display: contents } by default, but again, other CSS like *:nth-child() requires taking them into account.

3. Use an attribute

We would need to add a special attribute to all the parent nodes:

<h1>Some title</h1>
<h3>Some description</h3>
<div wp-children>I'm a child content</div>
<img wp-children src="with-an-image.png" />

This would not need to be as explicit because it doesn't affect CSS so I think it could be nicely abstracted.

The have two problems:

  1. Attributes need an HTML node

    <h1>Some title</h1>
    <h3>Some description</h3>
    I'm a child content
    <img wp-children src="with-an-image.png" />

    So we would have to detect texts and wrap them with <span>s:

    <h1>Some title</h1>
    <h3>Some description</h3>
    <span wp-children>I'm a child content</span>
    <img wp-children src="with-an-image.png" />
  2. We can't add them on the fly using PHP

    Imagine you have these $children, and you want to add the attributes:

    <div class="title">
      <h1>Some title</h1>
    </div>
    <div class="description">
      <h3>Some description</h3>
      <div>
        <div class="extra">
          <div>More content</div>
        </div>
      </div>
    </div>

    We would need proper HTML parsing to know that the top-level tags are only those with the classes title, description and extra.

    By the conversation on the WP_HTML_Walker pull request, I think we can assume that the API of the WP_HTML_Walker is as closer as we will ever get to HTML parsing. And that API is not capable of adding these attributes (it just finds tags; it doesn't know which tags are in the top-level).

    On the other hand, wrapping $children with <slot> it's a simple as:

    $wrapped_children = sprintf( '<slot name="children">%1$s</slot>', $children );

4. Figure out by comparing the DOM with the component output

That'd be similar to what the hydrate algorithm does: compare the DOM with the vDOM generated by the app and try to match it. In this case, instead of removing what doesn't match, it could treat it as children.

This solution is more complex, requires more computation, and a bigger initial bundle, so I'd try to avoid it if we can.


Any other ideas? 🙂

@yscik
Copy link
Collaborator

yscik commented Aug 31, 2022

For inner blocks, as alternative to the HTML comments as separators:

5. Text nodes with a set of non-printable unicode characters

  • These might get moved in the DOM in a few cases like tables and lists where a text node is an invalid children
  • Could be hard to work with since they're not visible in the source, but could be combined with comments for developers
<h3>Some description</h3>
<!-- slot --> ⌷⌷⌷⌷
    <div>I'm a child content</div>
    <img src="with-an-image.png" />
<!-- /slot --> ⌷⌷⌷⌷

6. <!CDATA[[ … ]]> or xml processing instructions like <?wp-inner-blocks ?>

  • These are technically not valid in HTML, but should appear as comment nodes in the DOM
  • Not sure if optimization plugins would still remove them
<h3>Some description</h3>
<?wp-start-inner-blocks ?>
    <div>I'm a child content</div>
    <img src="with-an-image.png" />
<?wp-end-inner-blocks ?>

7. HTML elements with an attribute before/after

  • These would still break some CSS like :first-child, but less
  • Similarly to text nodes they might be invalid as children in some nodes
<h3>Some description</h3>
<span wp-start-inner-blocks />
<div>I'm a child content</div>
<img src="with-an-image.png" />
<span wp-end-inner-blocks />

@luisherranz
Copy link
Member Author

luisherranz commented Aug 31, 2022

Nice ideas. Thanks, Peter 🙂

I especially like the comments + invisible characters idea.


I want to introduce another constraint: nested slots

Blocks can't nest other blocks in their implementations, but client components can. Client components are related to the task Experiment with ways not to hydrate the entire block but only some client components from the Tracking issue.

Imagine these two client components, one nesting the other:

const ClientComp1 = ({ children }) => {
  // some logic...
  return (
    <client-comp-1>
      <div class="from-client-comp-1">{children}</div>
    </client-comp-1>
  );
};
const ClientComp2 = ({ children }) => {
  // some logic...
  return (
    <client-comp-2>
      <client-comp-1>
        <div class="from-client-comp-2">{children}</div>
      </client-comp-1>
    </client-comp-2>
  );
};

If you use <client-comp-2> in your block:

const MyBlock = () => (
  <client-comp-2>
    <div>my block content</div>
  </client-comp-2>
);

You would end up with this SSRed HTML, where the slots are reversed:

<client-comp-2>
  <client-comp-1>
    <div class="from-client-comp-1">
      <!-- slot -->
      <div class="from-client-comp-2">
        <!-- slot -->
        <div>my block content</div>
        <!-- /slot -->
      </div>
      <!-- /slot -->
    </div>
  </client-comp-1>
</client-comp-2>

And the current algorithm wouldn't know how to differentiate between the slots.

I see two possible solutions so far:

  1. Add a unique id to the slots.

    <client-comp-2 wp-comp-id="xxx">
      <client-comp-1 wp-comp-id="yyy">
        <div class="from-client-comp-1">
          <!-- slot yyy -->
          <div class="from-client-comp-2">
            <!-- slot xxx -->
            <div>my block content</div>
            <!-- /slot xxx -->
          </div>
          <!-- /slot yyy -->
        </div>
      </client-comp-1>
    </client-comp-2>
  2. Create an algorithm that discards the same number of slots as the number of client components it finds

    <client-comp-2 wp-comp>
      <client-comp-1 wp-comp>
        <div class="from-client-comp-1">
          <!-- slot -->
          <div class="from-client-comp-2">
            <!-- slot -->
            <div>my block content</div>
            <!-- /slot -->
          </div>
          <!-- /slot -->
        </div>
      </client-comp-1>
    </client-comp-2>
    • Finds client-comp-2, which is a wp-comp, so it needs to discard one slot
    • Finds <!-- slot --> but discards it because of client-comp-2
    • Finds <!-- slot --> again, no more wp-comp in the middle, so this must be it

    It also works for slots that are deeper but not in the same branch.

    <client-comp-2 wp-comp>
      <client-comp-1 wp-comp>
        <!-- slot -->
        <div>child of client-comp-1</div>
        <!-- /slot -->
      </client-comp-1>
      <div>
        <div>
          <!-- slot -->
          <div>child of client-comp-2</div>
          <!-- slot -->
        </div>
      </div>
    </client-comp-2>

    I haven't thought much about this part, so there may be edge cases where this doesn't work.


Coming back to Peter's comments + invisible characters idea:

Maybe we could rely on comments for the slot position and, at the last moment, add the invisible characters dynamically with PHP:

  • Manual input of the invisible characters can be problematic.
  • Copying and pasting the invisible characters can be problematic.
  • But it's safe to save HTML comments in the content markup.
  • And it's safe to print HTML comments from PHP.

Also, if we need to add information to the slots, like a unique ID, we need to know that we can encode that information within the invisible characters and then decode it back. I'll ask @dmsnell about it. He's been studying them for the dynamic token implementation (although he finally discarded them).

@luisherranz luisherranz changed the title Solution to localte the children of an island or client component Solution to locate the children of an island or client component Aug 31, 2022
@luisherranz
Copy link
Member Author

luisherranz commented Aug 31, 2022

Another constraint I'd like us to put in the basket is compatibility with multiple slots. Again, this is something more for client components than for blocks, although it could end up being used in blocks too.

Imagine this client component:

const ClientComp = ({ children, header, footer }) => {
  // ...
  return (
    <client-comp>
      <header>{header}</header>
      <main>{children}</main>
      <footer>{footer}</footer>
    </client-comp>
  );
};

Something like this would need to be the SSRed output:

<client-comp>
  <header>
    <slot name="header">
      <h1>This is the header</h1>
    </slot>
  </header>
  <main>
    <slot name="children">
      <div>This is the main</div>
    </slot>
  </main>
  <footer>
    <slot name="footer">
      <div>This is the footer</div>
    </slot>
  </footer>
</client-comp>

If we'd ever want to support multiple slots and the ability to use these atomic client components inside one another, we could not use the algorithm I described in my previous comment (point 2.) because there would be an undetermined number of slots. We would need the unique id/hash solution (point 1.).


I've also been thinking about how we could abstract the insertion of these slots. Maybe we could use a <Slot> component and a wrapper for all the client components.

We also need to avoid the typical {show && children} because children needs to be always serialized in case it's shown again in the client.

It's indeed the "initially hidden children" problem of inner blocks (related to this issue), but generalized for smaller islands/client-component.

const propsContext = createContext(null);

const Wrapper = (props) => {
  <propsContext.Provider value={props}>
    <Comp {...props} />
  </propsContext.Provider>;
};

const Slot = ({ name, show }) => {
  const props = useContext(propsContext);

  return show ? (
    <slot name={name} show={show} hash={`slot-${props.hash}`}>
      {props[name]}
    </slot>
  ) : (
    <template slot={name} show={show} hash={`slot-${props.hash}`}>
      {props[name]}
    </template>
  );
};

// And the client component would do this:

const MyClientComponent = () => {
  const [show, setShow] = useState(false);

  return (
    <div>
      Some content
      <Slot name="children" show={show} />
    </div>
  );
};

@luisherranz
Copy link
Member Author

luisherranz commented Sep 2, 2022

I think we need a different solution for blocks (islands) and client components.

Blocks can't be nested, and they usually have a single wrapper node (although it doesn't seem to be mandatory). So I think we can use the attribute approach.

I wrote about it on

@luisherranz luisherranz changed the title Solution to locate the children of an island or client component Solution to locate the nodes and/or children of an island or client component Sep 5, 2022
@luisherranz
Copy link
Member Author

luisherranz commented Sep 5, 2022

EDIT: After realizing that internal markers are redundant when combined with slots with unique ids/hashes, and that some sort of <slot> wrapper is unavoidable, I've edited this comment to reflect that.


This is my proposal method to hydrate atomic/independent client components that support:

  • Nested independent components
  • Multiple slots
  • Out of order hydration

At first, I tried to make it work with internal node markers. For reference, you can view here the original proposal containing them.

View internal node markers proposal

1. Mark internal node

First, we start annotating all the internal nodes of a client component, as opposed to the wrapper nodes of the children. Something like this:

const ClientComp = ({ children }) => {
  // some logic...
  return (
    <client-comp>
      <div>
        <div>{children}</div>
      </div>
    </client-comp>
  );
};
const MyBlock = () => (
  <client-comp>
    <div>my block content</div>
  </client-comp>
);

The SSRed HTML would be:

<client-comp wp-comp>
  <div wp-internal>
    <div wp-internal>
      <div>my block content</div>
    </div>
  </div>
</client-comp>

The algorithm can discard all the internal nodes and pass the rest as children.

2. Use unique ids

Marking the internal nodes doesn't solve nested client components. We also need to add a unique hash/id.

Imagine these client components:

const ClientComp1 = ({ children }) => {
  // some logic...
  return (
    <client-comp-1>
      <div>{children}</div>
    </client-comp-1>
  );
};
const ClientComp2 = ({ children }) => {
  // some logic...
  return (
    <client-comp-2>
      <client-comp-1>
        <div>{children}</div>
      </client-comp-1>
    </client-comp-2>
  );
};

If you use client-comp-2 in your block:

const MyBlock = () => (
  <client-comp-2>
    <div>my block content</div>
  </client-comp-2>
);

You would end up with this SSRed HTML:

<client-comp-2 wp-comp>
  <client-comp-1 wp-comp wp-internal>
    <div wp-internal>
      <div wp-internal>
        <div>my block content</div>
      </div>
    </div>
  </client-comp-1>
</client-comp-2>

We neet to add a hash to the component (wc-${hash}) and the internal nodes (wi-${hash}):

<client-comp-2 wc-123>
  <client-comp-1 wc-456 wi-123>
    <div wi-456>
      <div wi-123>
        <div>my block content</div>
      </div>
    </div>
  </client-comp-1>
</client-comp-2>

Now the algorithm can do the following:

  1. Find client-comp-2 with hash wc-123. Let's look for its children (wi-123).
  2. Find client-comp-1
    • It's internal (wi-123). Ignore it.
    • And it's also a nested component (wc-456).
    • From now on, we should ignore its children (wi-456).
  3. Find div with wi-456
    • It's internal to client-comp-1. Ignore it.
  4. Find div with wi-123
    • It's internal to client-comp-2. Ignore it.
  5. Find div without hash
    • It's the children 🎉

And for client-comp-1:

  1. Find client-comp-1 with wc-456. Let's look for its children (wi-456).
  2. Find div with wi-456
    • It's internal. Ignore it.
  3. Find div with wi-123
    • It's not internal to client-comp-1: we found the children 🎉

It also works for slots that are not in the same branch.

Multiple branches example
const ClientComp1 = ({ children }) => {
  // some logic...
  return (
    <client-comp-1>
      <div>{children}</div>
    </client-comp-1>
  );
};
const ClientComp2 = ({ children }) => {
  // some logic...
  return (
    <client-comp-2>
      <client-comp-1>
        <div>child of client-comp-1</div>
      </client-comp-1>
      <div>{children}</div>
    </client-comp-2>
  );
};
<client-comp-2 wc-123>
  <client-comp-1 wc-456 wi-123>
    <div wi-456>
      <div wi-123>child of client-comp-1</div>
    </div>
  </client-comp-1>
  <div wi-123>
    <div>my block content</div>
  </div>
</client-comp-2>

For client-comp-2:

  1. Find client-comp-2 with hash wc-123. Let's look for its children (wi-123).
  2. Find client-comp-1
    • It's internal (wi-123), ignore it.
    • And it's also a nested component (wc-456).
    • From now on, we should ignore its children as well (wi-456).
  3. Find div with wi-456
    • It's internal to client-comp-1, ignore it.
  4. Find div with wi-123
    • It's internal to client-comp-2, ignore it.
  5. Goes to the next sibiling
  6. Finds div with wi-123
    • It's internal to client-comp-2, ignore it.
  7. Finds div
    • It's the children 🎉

3. Identify slot attributes to support multiple slots

Imagine this component:

const ClientComp = ({ children, header, footer }) => {
  // ...
  return (
    <client-comp>
      <header>{header}</header>
      <main>{children}</main>
      <footer>{footer}</footer>
    </client-comp>
  );
};

The consumer would need to add slot attributes to the non-default slots:

const MyBlock = () => (
  <client-comp>
    <h1 slot="header">This is the header</h1>
    <div>This is the main</div>
    <div slot="footer">This is the footer</div>
  </client-comp>
);

And the produced SSR would be something like this:

<client-comp wc-123>
  <header wi-123>
    <h1 slot="header">This is the header</h1>
  </header>
  <main wi-123>
    <div>This is the main</div>
  </main>
  <footer wi-123>
    <div slot="footer">This is the footer</div>
  </footer>
</client-comp>

The algorithm should be able to find all the children and use the slot attribute to pass it as a different prop.

It also supports multiple slots among nested children.

Multiple slots in nested children example
const ClientComp1 = ({ children, description }) => {
  // some logic...
  return (
    <client-comp-1>
      <div>{children}</div>
      <div>{description}</div>
    </client-comp-1>
  );
};
const ClientComp2 = ({ children, header }) => {
  // some logic...
  return (
    <client-comp-2>
      <client-comp-1>
        <div>{header}</div>
        <div slot="description">description from client-comp-2</div>
      </client-comp-1>
      <div>
        <div>{children}</div>
      </div>
    </client-comp-2>
  );
};
const MyBlock = () => (
  <client-comp-2>
    <div>my block content</div>
    <div slot="header">my block header</div>
  </client-comp-2>
);

The SSRed HTML would be:

<client-comp-2 wc-123>
  <client-comp-1 wc-456 wi-123>
    <div wi-456>
      <div wi-123>
        <div slot="header">my block header</div>
      </div>
    </div>
    <div wi-456>
      <div slot="description" wi-123>description from client-comp-2</div>
    </div>
  </client-comp-1>
  <div wi-123>
    <div wi-123>
      <div>my block content</div>
    </div>
  </div>
</client-comp-2>

The algorithm will do the following:

  1. Find client-comp-2 with hash wc-123. Let's look for its children (wi-123).
  2. Find client-comp-1
    • It's internal (wi-123). Ignore it.
    • And it's also a nested component (wc-456).
    • From now on, we should also ignore its children (wi-456).
  3. Find div with wi-456
    • It's internal to client-comp-1. Ignore it.
  4. Find div with wi-123
    • It's internal to client-comp-2. Ignore it.
  5. Find div without hash
    • It's a child 🎉
    • It has slot="header" so we pass it as the header prop
  6. Find div with wi-456
    • It's internal to client-comp-1. Ignore it.
  7. Find div with slot="description and wi-123
    • It's internal to client-comp-2. Ignore it.
  8. Find div with wi-123
    • It's internal to client-comp-2. Ignore it.
  9. Find div with wi-123
    • It's internal to client-comp-2. Ignore it.
  10. Find div without hash
  • It's a child 🎉
  • It doesn't have slot so we pass it as the children prop

And for client-comp-1:

  1. Find client-comp-1 with wc-456. Let's look for its children (wi-456).
  2. Find div with wi-456
    • It's internal. Ignore it.
  3. Find div with wi-123
    • It's a child 🎉
    • It doesn't have slot, so we pass it as the children prop
  4. Find div with wi-456
    • It's internal. Ignore it.
  5. Find div with slot="description and wi-123
    • It's a child 🎉
    • It has slot="description" so we pass it as the description prop

Wrap the children with <slot> and use unique ids/hashes

Marking internal nodes with a unique hash/id solves the nested independent component problem and supports multiple slots, but it doesn't help us identify wrapperless children.

Imagine this:

const ClientComp = ({ children }) => {
  // some logic...
  return (
    <client-comp>
      <div>This is internal text</div>
      <div>{children}</div>
    </client-comp>
  );
};

const MyBlock = () => <client-comp>And this is some text</client-comp>;

The SSR'd HTML would be:

<client-comp wc-123>
  <div wi-123>This is internal text</div>
  <div wi-123>And this is some text</div>
</client-comp>

And now, imagine this:

const ClientComp = ({ children }) => {
  // some logic...
  return (
    <client-comp>
      <div>This is internal text</div>
      <div>And this is some text</div>
    </client-comp>
  );
};

const MyBlock = () => <client-comp></client-comp>;

It has the same SSR'd HTML:

<client-comp wc-123>
  <div wi-123>This is internal text</div>
  <div wi-123>And this is some text</div>
</client-comp>

There's no way to identify whether there is a wrapperless child or not.

So for now, I'd just wrap children with <slot> to get it working and see how using slot evolves.

<client-comp wc-123>
  <div>This is internal text</div>
  <div><slot ws-123 name="children">My block content<slot></div>
</client-comp>

As I mentioned above, we also need to prevent the hidden children problem ({show && children}), so we probably would need to expose a <slot> component anyway:

const ClientComp = () => {
  // some logic...
  return (
    <client-comp>
      <div>This is internal text</div>
      <div>
        <slot name="children" />
      </div>
    </client-comp>
  );
};

Using slots with a unique id/hash supports both nested client components, multiple slots and out-of-order hydration. We will add another comment explaining out-of-order hydration in more detail.

For reference, these are the alternative solutions I've discarded, just in case someone finds a way to make them work:

View discarded methods
  1. Check if children is a text node. If it is, wrap it in a (harmless) <span> node.

    I've discarded it because it'd be hard to identify when children is wrapped in a component that is wrapperless inside:

    const ClientComp = ({ children }) => {
      // some logic...
      return (
        <client-comp>
          This is internal text, and this is <ChildComp>{children}</ChildComp>
        </client-comp>
      );
    };
    
    // This is not an independent client component, it's bundled insdie `ClientComp`!!
    const ChildComp = ({ children }) => <>{children} and more text</>;
    
    const MyBlock = () => <client-comp>my block content</client-comp>;

    The virtual dom would be something like this:

    const vdom = {
      type: ClientComp, // Replaced from "client-comp"
      props: {
        children: [
          "This is the internal text, and this is ",
          {
            type: ChildComp,
            props: {
              children: ["my block content", " and more text"],
            },
          },
        ],
      },
    };

    Children can also be a combination of wrapped and wrapperless nodes, which complicates this even more:

    <div wp-internal>
      text
      <div>text</div>
      text
      <div>
        <div>text</div>
      </div>
    </div>
  2. Marking whether the text is internal or not in the wrapper

    We could use another attribute (for example, wit) when the text is internal.

    <client-comp wc-123>
      <div wit-123>This is internal text</div>
      <div wi-123>This is children</div>
    </client-comp>

    But this doesn't work when you mix text with children inside the same node:

    const ClientComp = ({ children }) => (
      <div>This is internal and this is {children}</div>
    );

One small disclaimer: Other frameworks like Astro or Fresh bundle all the nested client components together so they don't have this problem. In this case, they will bundle ClientComp1 inside the entry point of ClientComp2. But I'd rather try to handle that complexity here so we can keep the server-side rendering and the bundling of each client component separate.

@luisherranz luisherranz changed the title Solution to locate the nodes and/or children of an island or client component Experiment with ways to not hydrate the entire block but only some client components Sep 5, 2022
@luisherranz
Copy link
Member Author

I've been talking with @DAreRodz, and it turns out that if adding the <slot> is inevitable, the information provided by the internal markers is redundant. We've also explored some ideas to make out-of-order hydration viable. I'll post them tomorrow, along with an edit of my previous comment to clarify that internal markers are not required.

@luisherranz luisherranz changed the title Experiment with ways to not hydrate the entire block but only some client components Children slots in client components Nov 29, 2022
@luisherranz
Copy link
Member Author

Closing this for now, as this is only related to components that are hydrated in the client and output more than one tag.

We'll open again in the future if/when we explore this.

@luisherranz luisherranz closed this as not planned Won't fix, can't repro, duplicate, stale Feb 20, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants