-
Notifications
You must be signed in to change notification settings - Fork 789
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Produce Declarative Shadow DOM with Hydrate #4010
feat: Produce Declarative Shadow DOM with Hydrate #4010
Comments
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 Any update on when or if this is planned? With currently 16 upvotes this seems to spark some interest. |
@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 |
Just wanted to say that Porsche is also using Stencil, but they wrote their own SSR solution using Declarative ShadowDOM: |
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 Using
Compiling directly to web components with declarative shadow DOMs should theoretically remove the need for middleware like |
@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 The DSD cannot be part of the WebComponent because if you use the custom element directly in NextJS, like Now a React-based wrapper components will be called by NextJS and can therefor inject the DSD as seen in my example. 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. |
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.
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 |
Exactly, the goal is to render the
I mean the first. If you have a basic HtmlElement WebComponent, you specifiy it in your code by tag, like 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.
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
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 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.
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. |
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 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 However, there would be many benefits to removing the need for the component library consumers to use |
@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 <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 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 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. |
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. |
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 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 |
Maybe to give some more details regarding our solution:
|
FYI support for Declarative Shadow DOM (DSD) has been very recently merged and is already part of the HTML spec under whatwg/html#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: |
We have implemented support for this in #5792. Please give it a try by installing our dev build for this: npm install @stencil/[email protected] And provide feedback. Thanks! |
Thanks for your work Christian. Unfortunately all elements next to the style tag (which are part of the original render method) in the shadow root are duplicated on hydration. We render the following html: <my-main-navigation-item data-ssr="true">
<template shadowrootmode="open">
<style>
/* Removed for readability. */
</style>
<div class="lg:mr-[16px] lg:mt-[1px] lg:mb-[7px] lg:border-0"> <!-- this div is duplicated on hydration -->
<div class="main-navigation-item block relative font-text-bold ...">
<slot></slot>
</div>
</div>
</template>
<a href="#mainmenu3" target="_self">Main Menu 3</a>
</my-main-navigation-item> The style tag is injected by us in our React component on the server. Same like the new renderToString with serializeShadowRoot should do. This happens with every component we render on the server. Its like those slot projection bugs that existed (and still exist to some extend). |
Thanks @mayerraphael for taking a look at testing it out, I've received this feedback from my peers as well and will investigate. |
@mayerraphael can you provide a minimal reproducible example by any chance? |
@christian-bromann I created an isolated simple example here: https://github.com/mayerraphael/stencil-dsd-ssr-playground I faked the SSR part by sending the html with the declarative shadow dom in a fixed way. Upon hydration, the The component: import { Component, h } from '@stencil/core';
@Component({
tag: 'my-component',
styleUrl: 'my-component.css',
shadow: true,
})
export class MyComponent {
render() {
return <div><slot></slot></div>;
}
} How the component is send out using a declarative shadow dom (see server.js). <my-component>
<template shadowrootmode="open">
<style>
:host {
display: block;
}
</style>
<div><slot></slot></div>
</template>
<span>Hello SSR.</span>
</my-component>
<script type="module">
import {defineCustomElements} from "./static/loader/index.js";
defineCustomElements().catch(console.error);
</script> I hope that helps. |
This is not supported as Stencil relies on HTML comments created during the serialization process. Without them it won't be able to reconcile the elements properly. Applying the following patch made the example work: diff --git a/server.js b/server.js
index c75334c..606385f 100644
--- a/server.js
+++ b/server.js
@@ -2,29 +2,17 @@ import express from 'express';
const app = express();
const port = 3333;
+import { renderToString } from './hydrate/index.mjs';
+
app.use("/static", express.static("."));
-app.get('/', (req, resp) => {
- resp.send(`
- <my-component>
- <template shadowrootmode="open">
- <style>
- :host {
- display: block;
- }
- </style>
- <div>
- <slot></slot>
- </div>
- </template>
- <span>Hello SSR.</span>
- </my-component>
- <script type="module">
- import {defineCustomElements} from "./static/loader/index.js";
- defineCustomElements().catch(console.error);
- </script>
- `);
+app.get('/', async (req, resp) => {
+ const { html } = await renderToString(`<my-component>Hello SSR.</my-component>`, {
+ serializeShadowRoot: true,
+ fullDocument: false
+ });
+ resp.send(html);
});
diff --git a/stencil.config.ts b/stencil.config.ts
index c58df8b..0e98e8e 100644
--- a/stencil.config.ts
+++ b/stencil.config.ts
@@ -19,6 +19,9 @@ export const config: Config = {
type: 'www',
serviceWorker: null, // disable service workers
},
+ {
+ type: 'dist-hydrate-script',
+ }
],
testing: {
browserHeadless: "new", |
@christian-bromann Thanks for your reply. I would've not expected that those things would still be required with the new solution. The whole reason for Declarative Shadow DOM is to be able to let the HTML/CSS inside the component as-is so no reconstruction is required, just "real hydration" (attaching handlers). I think Stencil should be able to hydrate without any weird CSS scoping (which was part of the feature request) and HTML comments (and mutation like c-id) like most frameworks out there. Edit: The current solution would require, again, to first serialize the whole React/NextJS page and then pass everything to renderToString. This does not allow:
Really curious how #5831 will be solved without those drawbacks. Especially how the increment s-id/c-id stuff will work if individual components are rendered. But really, thanks! This is a step in the right direction, even though still hard to apply in a real-world use case. |
@mayerraphael thanks a lot for your feedback!
This makes sense. I would love to track this in a separate issue as I believe it requires a new approach on how we hydrate components in general. We already had conversations within them team of getting rid of the HTML comments but this may be a breaking change that we have to carefully evaluate. Hence I suggest to track this as a next step.
How so? You can serialize/hydrate individual components by setting Keep an 👁️ on #5831 , I would love to get your feedback on this. I hope to have something to test for you within the next 1/2 weeks. |
Because the current solution can only work with strings? Or maybe i am missing something crucial here. Lets take a normal page in NextJS: import { MyStencilComponent } from "stencil-react-output-target".
export default function MyPage() {
return (
<div>
<MyStencilComponent prop="hello">
<span>World</span>
<MyStencilComponent>
</div>
)
} How would serialization work in this case? The only way i currently see is a custom server which serializes the NextJS page (with Your solution, extending the React output target: Does this mean inside the wrapper the contents are serialized to string and some kind of dangerouslySetInnerHTML element is rendered? Or do you plan on returning React compatible VNodes (with the HTML attributes/comments added) so the serialization can be done by native React/NextJS? The ideal case would be if Stencils React ouput target would provide React compatible JSX/VNodes (or calling a method which serializes not to string, but to VDOM) so nothing has to be done by Stencil on the server (maybe the VNodes have the required HTML attributes/comments included). But as s-id is incremental and unique for each rendering and the c-ids are dependent on s-id (
I understand. One step after the other 👍🏼 |
We are working on an enhancement on the React output target which would provide better support for Next.js. The approach is to wrap the Stencil component to use the
Correct, it may look something like this: import { renderToString } from 'component-library/hydrate';
export const MyButton = typeof globalThis.window !== 'undefined'
/**
* export React output target
*/
? createComponent<MyButtonElement, MyButtonEvents>({
tagName: 'my-button',
elementClass: MyButtonElement,
react: React,
events: {
onMyFocus: 'myFocus',
onMyBlur: 'myBlur'
} as MyButtonEvents,
defineCustomElement: defineMyButton
})
:
/**
* export serialized version of component for server side rendering
*/
async (props: React.PropsWithChildren<{}>) => {
const { html } = await renderToString(
`<my-button ${reactPropsToStencilAttrs(props)}></my-button>`,
{
serializeShadowRoot: true,
fullDocument: false
}
);
return (
<my-button suppressHydrationErrors>
<template shadowrootmode="open" dangerouslySetInnerHTML={{
__html: html
}} />
{ props.children }
</my-button>
)
}
Yes, these are required at runtime for Stencil to reconcile the VDOM correctly. They are indeed random which is why the Thanks for all your great feedback so far. |
The initial work for this was included in today's v4.19.0 release! Please let us know of any bugs or complications in a new issue. |
### Release Notes <details> <summary>ionic-team/stencil (@​stencil/core)</summary> ### [`v4.19.0`](https://togithub.com/ionic-team/stencil/blob/HEAD/CHANGELOG.md#-4190-2024-06-26) [Compare Source](https://togithub.com/ionic-team/stencil/compare/v4.18.3...v4.19.0) ### Bug Fixes * **compiler:** support rollup's external input option ([#3227](ionic-team/stencil#3227)) ([2c68849](ionic-team/stencil@2c68849)), fixes [#3226](ionic-team/stencil#3226) * **emit:** don't emit test files ([#5789](ionic-team/stencil#5789)) ([50892f1](ionic-team/stencil@50892f1)), fixes [#5788](ionic-team/stencil#5788) * **hyrdate:** support vdom annotation in nested dsd structures ([#5856](ionic-team/stencil#5856)) ([61bb5e3](ionic-team/stencil@61bb5e3)) * label attribute not toggling input ([#3474](ionic-team/stencil#3474)) ([13db920](ionic-team/stencil@13db920)), fixes [#3473](ionic-team/stencil#3473) * **mock-doc:** expose ShadowRoot and DocumentFragment globals ([#5827](ionic-team/stencil#5827)) ([98bbd7c](ionic-team/stencil@98bbd7c)), fixes [#3260](ionic-team/stencil#3260) * **runtime:** allow watchers to fire w/ no Stencil members ([#5855](ionic-team/stencil#5855)) ([850ad4f](ionic-team/stencil@850ad4f)), fixes [#5854](ionic-team/stencil#5854) * **runtime:** catch errors in async lifecycle methods ([#5826](ionic-team/stencil#5826)) ([87e5b33](ionic-team/stencil@87e5b33)), fixes [#5824](ionic-team/stencil#5824) * **runtime:** don't register listener before connected to DOM ([#5844](ionic-team/stencil#5844)) ([9d7021f](ionic-team/stencil@9d7021f)), fixes [#4067](ionic-team/stencil#4067) * **runtime:** properly assign style declarations ([#5838](ionic-team/stencil#5838)) ([5c10ebf](ionic-team/stencil@5c10ebf)) * **testing:** allow to re-use pages across it blocks ([#5830](ionic-team/stencil#5830)) ([561eab4](ionic-team/stencil@561eab4)), fixes [#3720](ionic-team/stencil#3720) * **typescript:** remove unsupported label property ([#5840](ionic-team/stencil#5840)) ([d26ea2b](ionic-team/stencil@d26ea2b)), fixes [#3473](ionic-team/stencil#3473) ### Features * **cli:** support generation of sass and less files ([#5857](ionic-team/stencil#5857)) ([1883812](ionic-team/stencil@1883812)), closes [#2155](ionic-team/stencil#2155) * **compiler:** generate export maps on build ([#5809](ionic-team/stencil#5809)) ([b6d2404](ionic-team/stencil@b6d2404)) * **complier:** support type import aliasing ([#5836](ionic-team/stencil#5836)) ([7ffb25d](ionic-team/stencil@7ffb25d)), closes [#2335](ionic-team/stencil#2335) * **runtime:** support declarative shadow DOM ([#5792](ionic-team/stencil#5792)) ([c837063](ionic-team/stencil@c837063)), closes [#4010](ionic-team/stencil#4010) * **testing:** add `toHaveLastReceivedEventDetail` event spy matcher ([#5829](ionic-team/stencil#5829)) ([63491de](ionic-team/stencil@63491de)), closes [#2488](ionic-team/stencil#2488) * **testing:** allow to disable network error logging via 'logFailingNetworkRequests' option ([#5839](ionic-team/stencil#5839)) ([dac3e33](ionic-team/stencil@dac3e33)), closes [#2572](ionic-team/stencil#2572) * **testing:** expose captureBeyondViewport in pageCompareScreenshot ([#5828](ionic-team/stencil#5828)) ([cf6a450](ionic-team/stencil@cf6a450)), closes [#3188](ionic-team/stencil#3188) </details>
Prerequisites
Describe the Feature Request
Add a feature to the hydrate package so the
renderToString
function produces static html using Declarative Shadow DOMs' tag instead of the current custom solution.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:
<template shadowroot="open">
. For Firefox, a simple polyfill can be used.<template>
.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).Describe Preferred 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
The text was updated successfully, but these errors were encountered: