stencil icon indicating copy to clipboard operation
stencil copied to clipboard

feat: Produce Declarative Shadow DOM with Hydrate

Open mayerraphael opened this issue 1 year ago • 15 comments

Prerequisites

Describe the Feature Request

Add a feature to the hydrate package so the renderToString function produces static html using Declarative Shadow DOMs'

Terminology used: Declarative Shadow DOM = DSD.

Resources: https://caniuse.com/declarative-shadow-dom https://github.com/mfreed7/declarative-shadow-dom

Describe the Use Case

The current hydrate pacakge is good, thanks for that as we all know how hard WebComponents and SSR are, but with the implementation of DSD in Webkit and easy Polyfils, the hydrate script should have an option which produces Declarative Shadow DOM.

Please keep in mind that my internal knowledge of Stencil is limited, I take some assumptions with the benefits this feature could have.

What are the benefits:

  • The browser automatically creates a shadow-root fragment from <template shadowroot="open">. For Firefox, a simple polyfill can be used.
  • This also means that the WebComponents can be 100% compatible with React/SSR, as React only sees the same tree as the one specified in the template. No hydration warnings anymore.
  • No extra css-scoping and de-scoping required. Just place the css as is inside a
  • renderToString may not require the full html anymore, as no mutations to <head> are needed. It would only need the code of the component(s).
  • Hydration does need not a full re-rendering. The contents of the DSD are still present in the #shadow-root fragment.
  • Currently this means that Stencil must hydrate components before any other framework does, otherwise it would cause for example React Hydration Errors as the DOM does not match.

Describe Preferred Solution

  • "Stencil Hydrate" should allow to render components using Declarative Shadow DOM
  • "Stencil Hydrate"s' hydration logic should work with existing ShadowRoot and Nodes upon initialization to avoid unecessary re-rendering.
  • Styles should be usable without the current custom scoping solution.

Additional Information

I dont have any information on how Firefox plans to support DSD, but for cross-framework-compatible SSR i see no way around DSD.

Edit: Firefox DSD implementation is finished: https://bugzilla.mozilla.org/show_bug.cgi?id=1712140

mayerraphael avatar Jan 29 '23 10:01 mayerraphael

Thanks for the detailed request @mayerraphael! Feature requests like these are very much appreciated. This is something we've been keeping an eye on, and don't have an existing issue/feature request for. I'm going to label this to try to gauge interest in something like this, which helps from a prioritization standpoint. Thanks again!

rwaskiewicz avatar Jan 30 '23 14:01 rwaskiewicz

@rwaskiewicz Any update on when or if this is planned? With currently 16 upvotes this seems to spark some interest. I guess this would also simplify the current custom hydration logic massivly (maybe some problems that needed to be fixed with PR 2938)

mayerraphael avatar Mar 29 '23 08:03 mayerraphael

@mayerraphael It's moved up on our backlog to do some spike work around this, but I don't have a concrete timeline to share at this time

rwaskiewicz avatar Mar 29 '23 17:03 rwaskiewicz

@rwaskiewicz

Just wanted to say that Porsche is also using Stencil, but they wrote their own SSR solution using Declarative ShadowDOM:

https://github.com/porsche-design-system/porsche-design-system/blob/aa8177ff61c916c8c6872844650fd2b0bfaf1d23/packages/components/scripts/generateDSRComponents.ts#L121

mayerraphael avatar Jun 27 '23 06:06 mayerraphael

Not sure if this is possible, but it would be awesome if generating declarative shadow DOMs happened via a build flag rather than as an output of renderToString.

Using renderToString with SSR frameworks requires swapping in a custom server in a lot of cases, which removes some of the frameworks' benefits. For example, Next.js says:

Before deciding to use a custom server, please keep in mind that it should only be used when the integrated router of Next.js can't meet your app requirements. A custom server will remove important performance optimizations, like serverless functions and Automatic Static Optimization.

A custom server cannot be deployed on Vercel.

Compiling directly to web components with declarative shadow DOMs should theoretically remove the need for middleware like renderToString.

benelan avatar Sep 23 '23 08:09 benelan

@benelan This would be the job of the React (Wrapper) component then.

I've created a repo which shows how DSD works with a handwritten WebComponent and NextJS: https://github.com/mayerraphael/nextjs-webcomponent-hydration

It works WITHOUT a custom server.js (updated just now with a working StencilJS component).

The DSD cannot be part of the WebComponent because if you use the custom element directly in NextJS, like <my-component />, it does not call a render function on that component, but renders the tag as is.

Now a React-based wrapper components will be called by NextJS and can therefor inject the DSD as seen in my example. Porsche does the same: their custom SSR code generates React components from the Stencil source files.

I will extend my example soon using StencilJS and try to create a generic wrapper around Stencil components so they can be used with SSR/DSD.

mayerraphael avatar Sep 23 '23 18:09 mayerraphael

Thanks for the info and sample @mayerraphael! I haven't worked with DSDs yet, but my understanding is the templates will be rendered on the server, and then the custom elements will hydrate on the client. This Chrome article talks about that a bit.

The DSD cannot be part of the WebComponent because if you use the custom element directly in NextJS, like <my-component />, it does not call a render function on that component, but renders the tag as is.

Do you mean it literally renders the component tag as text or only renders the template?

If it's the latter, that may be a React-specific issue with their VDOM implementation not re-rendering once the custom element hydrates. Stencil provides a React wrapper output target that should be able to help wire up the DSDs and force a rerender after hydration, if that's the issue.

I just used NextJS as an example but there are plenty of other SSR/SSG frameworks like Astro/SveleKit/NuxtJS that won't run into React's web component weirdness.

Note: I was able to prerender Stencil components in NextJS using getStaticProps instead of a custom server at one point, but it was pretty fragile. Here is the sample if anyone is curious.

benelan avatar Sep 25 '23 23:09 benelan

Thanks for the info and sample @mayerraphael! I haven't worked with DSDs yet, but my understanding is the templates will be rendered on the server, and then the custom elements will hydrate on the client. This Chrome article talks about that a bit.

Exactly, the goal is to render the <template shadowrootmode> Tag, which the Browser converts on parsing the HTML to a ShadowRoot Fragment. This means we have a working Shadow Root even before the component hydrates on the client.

The DSD cannot be part of the WebComponent because if you use the custom element directly in NextJS, like <my-component />, it does not call a render function on that component, but renders the tag as is.

Do you mean it literally renders the component tag as text or only renders the template?

I mean the first. If you have a basic HtmlElement WebComponent, you specifiy it in your code by tag, like <my-component>. There is no import or class related for NextJS to instantiate that class and call render() on. So it cannot now the "internals" of the component. It only places the tag as is in the HTML. It is like you write a <div> tag.

You can try it yourself. Just normally place your custom element by tag name () inside a NextJS page and disable JavaScript in the Browser or check the generated html. The components content will not be visible (only the children, as they can be rendered by NextJS depending if they are webcomponents too or not) until the component hydrates.

If it's the latter, that may be a React-specific issue with their VDOM implementation not re-rendering once the custom element hydrates. Stencil provides a React wrapper output target that should be able to help wire up the DSDs and force a rerender after hydration, if that's the issue.

We are not on the client yet, that is another topic with problems regarding Reconciliation React Issue. This can be worked around by providing different VDOM on client and on server, as seen here. This is basically a custom React Wrapper (like the React Stencil Output Target creates, but more incomplete) which handles the DSD on the server and no-dsd on the client so React does not create hydration errors because of VDOM mismatches.

You are right that most likely all the SSR/DSD wiring has to happen in the React/Vue/... Output Targets .

I just used NextJS as an example but there are plenty of other SSR/SSG frameworks like Astro/SveleKit/NuxtJS that won't run into React's web component weirdness.

They also do, at least with SSR/DSD. The problem is always the same. With a native WebComponent, you need to integrated it into the Framework so it can render the contents of the WebComponent on the server, as WebComponents are a client side only construct by nature (with the CustomElementRegistry and so on) and may use other libraries (as Stencil does with copying/adjusting Preact) to render. So you need to "tell" the framework on how to run your component and what to do with the "render()" result.

Stencil uses a custom VNode format here, derived from Preact. Because of that we need to convert the result of the render() call to another VNode format, whatever you use for rendering.

This is what i'm doing here. Instantiate the Component class (pass an empty hostref WeakMap), apply props and call the render method. Then i convert the StencilJS VNodes to Preact VNodes, so i can render them on the server. Would also be possible to convert them directly to React VNodes, but i found using preact-render-to-string easier.

Note: I was able to prerender Stencil components in NextJS using getStaticProps instead of a custom server at one point, but it was pretty fragile. Here is the sample if anyone is curious.

I see you use the hydrate renderToString. This is what we want to get away from. The current hydrate renderToString is bad for all the reasons listed in the initial issue comment.

mayerraphael avatar Sep 26 '23 06:09 mayerraphael

That all makes sense, thanks for taking the time to write up the response! I'm looking forward to playing with DSDs more.

I think my initial suggestion of generating web components with DSDs during Stencil's component compilation is still valid though. It sounds like we are in agreement that Stencil's framework wrapper output targets should hold the responsibility of wiring up the SSR/DSDs. So renderToString shouldn't be required at all if the web components already have DSDs.

This is all theoretical, I have no idea if it is possible with Stencil's component architecture or compilation process. And if it is possible, I'm sure it would be a much larger undertaking than switching renderToString to generate DSDs.

However, there would be many benefits to removing the need for the component library consumers to use renderToString. It would reduce server render time, which can be pretty significant for very large applications. It would also reduce/eliminate the extra steps required for users to get the component library set up with SSR. The steps for using renderToString will differ depending on the SSR framework, and may not even be possible/stable in some frameworks.

benelan avatar Sep 28 '23 19:09 benelan

@benelan I'm curious what you're proposing with generating components with DSD during Stencil compilation. My understanding is that DSD is an "in-html" construct, so at a minimum Stencil would have to start distributing some sort of artifact that's a bit different than what we distribute now for the hydrate app output target (or any of the others for that matter).

There would be some advantages to having Stencil produce some sort of DSD 'intermediate representation' itself (whatever that might look like), namely that then the framework wrappers would be able to all consume a unified format instead of having to DIY it, and additionally Stencil developers who don't use a framework would also likely be able to find a way to take advantage of DSD in their applications.

Where it gets a bit interesting is what that DSD 'IR' would look like, as DSD is an in-HTML construct. Since the rendered output of a Stencil component is generally going to be dependent on the props it receives, and prop values wouldn't be known at compile-time, we wouldn't be able to just produce a little html fragment. So maybe a per-component function called something like renderDeclarativeShadowDOM which takes the component's props as an argument and returns an HTML string like

<my-component>
  <template shadowrootmode="open">
    <style>{{ component styles }}</style>
    {{ rendered component html }}
  </template>
</my-component>

I would think that a function like that could be called in the framework wrappers or directly for both SSR and SSG use-cases to cover both dynamic components which need per-page-view props and static components which only need to be rendered once per page, but the details of how exactly that would happen would be a bit of a downstream concern from Stencil itself.

That feels like something that would give a lot of flexibility for generating a DSD for a given Stencil component, while also not bringing the responsibility for all of this into Stencil itself.

Is that along the lines of what you're thinking @benelan or something else?

alicewriteswrongs avatar Sep 29 '23 14:09 alicewriteswrongs

@alicewriteswrongs I hadn't considered prop values when I was thinking about this, but your potential solution sounds great.

My main goal is for our component library consumers to be able to use the framework wrappers in SSR and SSG apps without needing to use renderToString on their end. There would likely need to be some kind of browser/server check added to the framework wrappers so they still work in SPA apps. Although there might be a way to generate and render DSDs that works on both the client and server for some frameworks, but I'm not sure.

It sounds like the solution you're envisioning would allow for that, which would be awesome! Being able to manually wire up the generated DSDs in frameworks without wrapper components (like Astro) is a big plus too.

benelan avatar Oct 05 '23 20:10 benelan

In case you haven't seen yet, Lit has an experimental SSR solution that takes advantage of DSDs. It could be helpful to see how they did it while you plan Stencil's implementation.

benelan avatar Oct 05 '23 22:10 benelan

Definitely something to keep considering and noodling on here! I suspect you're right that for some frameworks it would probably be relatively doable to generate one component that will work for both a server-side and client-side case if Stencil exported a first-class way to generate a DSD (i.e. a <template> tag) for the elements but I will also add a big caveat that at present we haven't yet tried to do any of this so when any implementation actually starts things could always end up being a good deal more complicated than we think.

When I think in my head about how this could work in react + nextjs, for instance, I can kind of visualize a path through where Stencil exports some way to generate a DSD from props, a React component wrapper either calls that function or static JSX is generated based on that (or something along those lines) and then Stencil's component runtime is modified to support gracefully taking over components initialized with DSD, allowing a React component wrapping a Stencil component to be server-rendered or client-rendered, with the runtime gracefully "doing the right thing" in either case, but that's actually a lot of pieces to get working together so a lot of opportunities for things to be more difficult than would be ideal 😅

As a sort of meta-comment on this issue, the Stencil team is looking to do more exploration and research about this soon and while I can't promise a definite timeline it is definitely something we're looking at

alicewriteswrongs avatar Oct 06 '23 14:10 alicewriteswrongs

@rwaskiewicz

Just wanted to say that Porsche is also using Stencil, but they wrote their own SSR solution using Declarative ShadowDOM:

https://github.com/porsche-design-system/porsche-design-system/blob/aa8177ff61c916c8c6872844650fd2b0bfaf1d23/packages/components/scripts/generateDSRComponents.ts#L121

Maybe to give some more details regarding our solution:

  • we do framework wrapper generation ourselves for Angular, React and Vue
  • the SSR support for Next.js and Remix is an extension of the React wrappers, so it currently only works with React SSR
  • it's quite messy, especially the countless replacements and transformations done in generateDSRComponents.ts that transform the raw render functions of the Stencil components into something usable by React
  • the switch between what is rendered on server side / client side is based on process.browser which needs to be injected/replaced with true | false during build: https://github.com/porsche-design-system/porsche-design-system/blob/41a6f853db436ca8f0d286be584b96ca0ec2b4d0/packages/components/scripts/wrapper-generator/NextJsReactWrapperGenerator.ts#L69-L79
  • then we rely on dead code elimination to not ship the DSR wrappers to the client
  • React SSR wrappers are shipped via sub package ssr of https://www.npmjs.com/package/@porsche-design-system/components-react
  • we also had to patch stencil core to make bootstrapping (you may also call it hydration) work since it does not have to create a shadow root, when there is already a DSR
  • upon client side initialization the web component just rerenders everything within which leads to flickering when there are nested web components since it does not do real hydration which would just add event listeners and not rerender

denyo avatar Oct 31 '23 11:10 denyo

FYI support for Declarative Shadow DOM (DSD) has been very recently merged and is already part of the HTML spec under https://github.com/whatwg/html/pull/9538

which was mentioned in this article: https://developer.chrome.com/en/articles/declarative-shadow-dom/

It would be a dream for a clean out-of-the-box way to integrate Next.js with Stencil web components without workarounds or performance penalties.

No qualified nor out-of-the-box anwers under:

  • https://stackoverflow.com/questions/76769253/how-to-integrate-stencil-web-components-in-nextjs-implementing-ssr-not-csr
  • https://stackoverflow.com/questions/76211672/using-web-components-in-react-sever-components

igorlino avatar Nov 24 '23 12:11 igorlino