facebook/react

Bug: Reconciler cannot handle Declarative Shadow DOM (DSD)

mayerraphael opened this issue ยท 11 comments

The reconciler does not ignore <template shadowRoot="open"> but handles them like a normal HostElement.
In reality, as soon as the closing template tag is parsed, the component is replaced in the DOM by #shadow-root (open)

See: https://github.com/mfreed7/declarative-shadow-dom#-behavior

React version: 18.2.0

Steps To Reproduce

I tried this with NextJS 13.1.6, which uses react 18.2.0 and react-dom 18.2.0.

In the end the component is rendered server side and hydrated in the frontend.

  1. Add the following html code to your component
<div>
  <template shadowrootmode="open"> 
    <button type="button">
      <slot></slot>
    </button>
  </template>
  My button
</div>  
  1. Render the component via SSR and hydrate in the frontend.

The current behavior

A hydration warning is thrown:

Expected server HTML to contain a matching <template> in <div>.
    at template
    at div
    at Home 

The expected behavior

In the end i guess a DSD should be handled as an isolation block where on the server the DSD template tag is allowed, but on the client hydration all children of the block are hydrated to the now existing ShadowRoot.

Server -> Render template tag
Client -> Children of template tag are hydrated against the ShadowRoot fragment.

Note that it's now shadowrootmode and not shadowroot.

Just removing my previous comments as they weren't as relevant as I believed before I got this far down the rabbit hole. For the time being, I may just steal some of your documentation about the suggestion to patch react-dom, I'm glad you identified this issue and it helped me understand it.

jonathandewitt-dev/react-shadow-scope#2

o-t-w commented

I tried this in the latest version of both Remix and Next.js. Itโ€™s definitely an issue.

@gaearon Is this on anyone's radar to fix? The issues are very distracting in my current Next project. I'd really rather not abandon React and Next, but unless this issue gets fixed, I might find myself with no other option.

Side note @mayerraphael, at some point Next started precompiling react-dom so now you have to make changes to this thing to see any difference:

node_modules/next/dist/compiled/react-dom/cjs/react-dom.development.js

In case someone lands here from Google, here's what I had to do to make it work: Search for function beginWork$1. On the line BEFORE switch (workInProgress.tag), add the following:

var isDSD = workInProgress.type === 'template' && (
  workInProgress.pendingProps.shadowrootmode === 'open'
  || workInProgress.pendingProps.shadowrootmode === 'closed'
);

if (isDSD) {
  return null;
}

@jonathandewitt-dev in the end i solved it by only rendering the <template> element on the server. On the client you hydrate the content without the enclosing DSD and hydrate directly into the existing shadowRoot fragment.

I created a POC in Preact/Deno:
See client side hydration here: https://github.com/mayerraphael/webcomponent-streaming-ssr-deno/blob/873c92be4749507d5430b99f388aaa7f819697ee/platform/ui/component.ts#L84C13-L84C13

Se DSD enclosing on server here: https://github.com/mayerraphael/webcomponent-streaming-ssr-deno/blob/873c92be4749507d5430b99f388aaa7f819697ee/platform/ui/runtime.mts#L40

Hmm, as far as I can tell, that project is not even depending on react-dom, so I'm not sure that resolves the issue at hand...

It is about the core concept.

Server -> Render tag, DSD and component nodes
Client -> Hydrate component nodes using existing ShadowRoot.

There is no reason the handle DSD on the client at all. At least not the tag, but React must replace it on client hydration with the ShadowRoot as the hydration root.

It is also not correct as i wrote in my initial post here that React should ignore the <template shadowroot="open"> tag and all its children

Cases like having an onClick here should of course still work:

<div>
  <template shadowrootmode="open">  // If we ignore everything from the DSD onwards, onClick in the next line will not hydrate.
    <button type="button" onClick={handleClick}>
      <slot></slot>
    </button>
  </template>
  My button
</div>  

The isolation of WebComponents and hydrating only specific ShadowRoots helps here. Meaning you can render something for a component on the server (tag, dsd, content) and use a different render/hydration target and just the component content on the client.

The only solution i could think of would be that React handles DSD <template> tags as an isolation block, meaning on hydration it searches for an ShadowRoot fragment in place of the DSD and hydrates all the children to that ShadowRoot.

Cases like having an onClick here should of course still work

I had a similar thought as well. I agree wholeheartedly with the concept. I'm going to see if I can fix this in react-dom. I'll fork it for now, and make a PR.

Side note @mayerraphael, at some point Next started precompiling react-dom so now you have to make changes to this thing to see any difference:

node_modules/next/dist/compiled/react-dom/cjs/react-dom.development.js

In case someone lands here from Google, here's what I had to do to make it work: Search for function beginWork$1. On the line BEFORE switch (workInProgress.tag), add the following:

var isDSD = workInProgress.type === 'template' && (
  workInProgress.pendingProps.shadowrootmode === 'open'
  || workInProgress.pendingProps.shadowrootmode === 'closed'
);

if (isDSD) {
  return null;
}

To follow up on this, I've been encountering some strange issues after doing this. HMR breaks and random onClick listeners never get applied properly. This issue just keeps coming back to haunt my nightmares.

@jonathandewitt-dev

I'm currently working on updating my NextJS/WebComponent Repo by getting a StencilJS component working

Once this is done i will have a look on what needs to be adjusted in react-dom so i do not have to handle server and client differently because of the shadowRoot template tag, as seen here.