Sitemap

ITNEXT

ITNEXT is a platform for IT developers & software engineers to share knowledge, connect, collaborate, learn and experience next-gen technologies.

Press enter or click to view image in full size

A deep analysis into isomorphic, autonomous cross-framework usage #MicroFrontends

26 min readMar 17, 2020

--

This article might be considered to not be an easy read. So take your time and focus 👌 It is crucial to read through if you want to understand the in-depth problems of the connection of Frameworks, their SSR and WebComponents and the issues that you encounter with the “all-problem-solving Web Components Micro Frontend” (which obviously is an ironic exaggeration).

Vue will be used for most of the samples but the principles are applicable to other frameworks.

Preamble:

Everyone is buzzword-bingoing around with #MicroFrontends. Everyone has an opinion — which is good. But I favor additional facts. Pros and Cons, underlying technical requirements and issues. Both private as well as in my work at Mercedes-Benz.io I love to get my head around the complexity of autonomous components. This article will try to shed light on the following question

How would one integrate WebComponents with different frameworks whilst being able to do SSR?

This article will not explain WHY to use WebComponents or SSR. This is an architectural article of feasibility, pitfalls and technical details. For digging deeper in the respective specifics I added a bunch of sources at the end.

Short side trip to isomorphism (skip if you know isomorphism)

The major requirement to do Server-Side Rendering is to render on the server what would be rendered on the client — This is called isomorphism.

The code sample is isomorphic — both server and client should return <strong>The default greeting text</strong>

Why is isomorphism so important?

The framework tries to not re-render any HTML if it was delivered via SSR. Only the additional steps such asaddEventListener When you do not have equal output (= no isomorphism) then most frameworks will re-render completely — which is costful. So you would lose the essential point of hydration. Check out the visualization diagram below.

Press enter or click to view image in full size

Short side trip to WebComponents

Some people like to discuss if a WebComponent by definition includes ShadowDOM etc. Let’s not be nit-pickers here and define the term WebComponent as

An element that is defined by the customElements API.

A minimal example: Now you could use <my-component></my-component> in your HTML

Now wherever you use <my-component> it will auto-initialize and say I am a simple component .

This means that you become framework-independent (and DOM independent) since you do not need to rely on existing elements that you explicitly initialize (such as often seen render('#app') ) but instead initialize in-place 🥰 :

connectedCallback() {
...
render(<Component />, this) // this = <my-component> instance
}

Let’s have Framework-based WebComponents

This is going to be a deep dive. The actual usage of WebComponents client-side is often very easy. See for example the React example here: https://reactjs.org/docs/web-components.html .

Press enter or click to view image in full size

However none of those popular frameworks are isomorphic by default (Stencil might be the exception but I didn’t incorporate it into the research yet).

Why WC+SSR provides headache:

On the server you need to define a component that renders a web component so that it can initialize on the client. Now when the client initializes it needs to render the Framework component. But we defined that Framework component to play a web component: You end up in an infinite loop.

Let us fiddle around how we can avoid the infinite loop

If one uses the component my-component on the client (frontend) then it must be available to the browser as customElement. So there is a prerequisite of having a customElements.define(‘my-component’, …) on the frontend side.

For simplicity lets define an underlying Vue.component :

This is a Vue.component definition that shall simply render the following

<my-component>
<div>Foobar</div>
</my-component>

To be able to hydrate this we need to register it in Vue.
Easy: Vue.component(‘my-component’, Component);

Congratulations! We created the mentioned infinite loop. my-component will render my-component which renders my-component ….

There is an official Web Components wrapper from Vue. Why don’t you use it? Using the official component wrapper is not an option since it is not isomorphic as well. You can already tell by its type of definition:

It is literally a wrapper to run your components only client-side. Also they did not evolve on that development for long time.

As of the date of writing this post the last commit from the official wrapper was in 2019 January

We need to find a solution that allows the following:

Your authoring system (static site, Drupal, AEM, Hybris, … ) plays out the following HTML that we shall render:

<my-component>
<my-component>
Hello encapsulation
<my-component>
</my-component>

As seen above we cannot define a Vue component that has the same tag name as its WebComponent.

So the next approach is is that every component has a specific app prefix that only is used on the clientside for customElements.define since then we can infer that the respectiveVue.component has the same name but without the prefix (to avoid the infinite loop).

Press enter or click to view image in full size

By that logic we would always be able to infer that app-my-component corresponds to component my-component. Let’s try it:

Now this would render the following nested example

<app-my-component>
<app-my-component>
Hello encapsulation
<app-my-component>
</app-my-component>

to a first-level-only one without the child and without the “Hello encapsulation part”:

<my-component>
<div>
Hello
</div>
</my-component>

We lost the inner child component because we do not have access to the children in this example (we will talk about Vue slot later). The result is not hydrated on frontend since we do not have a customElement registered that is called my-component . (If you ask yourself why then re-read the part about the infinite loop).

We can get rid of this mindfuck of naming by using anonymous components.

What we would expect when rendering this is that the childNode from my-app-component appears exactly where the <slot> sits in our Vue template. So we have to grab that childNode in our WebComponent and provide it back to Vue.

You can find a demo in this Sandbox sample. You will see that the output is not isomorphic. In fact the SSR one did nothing. It took the string, added the data-server-rendered="true" (since Vue adds that to everything that ran through the renderToString ) and that’s it. But why?

We are literally sending this to the server:

<app-my-component>
<strong>I am Foo</strong>
</app-my-component>

Now checkout our code from above. Do you see any definition of Vue.component('app-my-component', ..) ? No? Yup that’s the issue. And hopefully you remember why we do not have it: Infinite loop avoidance. You might have the quick brain zap like I did:

Well so I just define the app-my-component as a Vue component but only on the server so that there is no infinite loop on the client!?

For the sake of simplicity I would love to say: Try it. But I can tell you upfront: Will not work. For the client to work and recognize the components you would need to render an app-my-component tag from within the app-my-component Vue component. So you run into the exact same issue…

Let’s try to get the SSR one running in the next step. The next sandbox incorporates the knowledge that we know that the app-my-component has an underlying Vue definition called anonymous-app-my-component . So for SSR we are replacing the tag with the Vue tag. Now Vue can render it. But it will still replace the tag. So we need to wrap it with the component tag name again. See Sandbox sample here.

Press enter or click to view image in full size
tldr of this: Rewrite my-component to anonymous-my-component, render it and then manually wrap with my-component again for isomorphic HTML.

This works for this simple component but not when you are nesting other components because then you would only add the outer wrapper manually but all inner wrappers would be gone missing (cause again: Vue renders and replaces them). Also when we add our wrapper then the data-server-rendered from Vue is suddenly on the inside.

What we need to do is to define a Vue.component that is only used on the server side that then renders back into a tag that is known to be a CustomElement on the client.

That means we use the approach of renaming the tagName for the server but we rename it to something that the client will never ever see. The preprocessing before SSR in the demo

const htmlStringBeforeSSR = ... ;const ssrableString = htmlStringBeforeSSR.replace(
new RegExp("app-my-component", "g"),
"server-app-my-component"
)

See the working and nestable Sandbox sample here. 🥳
Let’s take a deep breath here and digest before we evaluate this further.

So far (without going deeper into all posssible use-cases) this would solve the infinite-loop issue and we could have isomorphism as it seems.

Unfortunately that is not yet the solution:

  1. We only have integrated Vue by now
  2. The part of Vue we did integrate unfortunately is only partially isomorphic and therefore not usable in real use cases. If we render slots with SSR we loose the context what part of the HTML the slots were. You are literally rendering pure native HTML to the client. Now how would you identify what was the child that was provided to the server as the <slot> replacement? We need to be able to identify the original childNodes from the server on the client, clone them from the server-rendered DOM to put them as a render-child back to the component instance to be able to hydrate. There will be more details in a few moments.
  3. The slot children can be web components again so they could trigger themselves an init process and change their html structure autonomously (since they underly a rendering template as well). The parent component would therefore go out-of-sync with its child component since the DOM change in the child component was not handled and rendered by the parent but within the instance of the child web component. 🧐

Why do you even need isomorphism? Can’t we just shortcut this and use Puppeteer?

This is actually a very good idea if you want to pre-render for SEO. But for our use-case it will not work:

1. Async rendered components especially those with loading state might leave the headless-browser-rendered one with a corrupt state (maybe avoidable though).

2. Some Frameworks need some identification of SSR (e.g. data-server-rendered=true). They are not added when CSR is happening with Puppeteer. So we would have to postprocess those (theoretically we could do that with a workaround but here it already gets really messy).

3. How would hydration look like for an element that has children? Since the children would not be easy to identify (as we analyzed in all of the above text).

We would not have any added benefit using Puppeteer.

Let us go deeper into that children issue that we have.

Have a look at the following rendering on the server (React again):

Hydration expects the same parameters on the client that you gave one the server. This allows the framework to build a virtual DOM Tree that can be diffed. In simple words we need

.hydrate(
<CoolComponent>
<strong>hello</strong>
</CoolComponent>
)

But we do not even know that we are supposed to render this at this point anymore. Our CoolComponent is reflected on the client by

// pseudo-code 🔽
customElements.define('cool-component', ...

hydrate/render(<CoolComponent>$children</CoolComponent>)
);

SSR provided us with the fully rendered HTML

<cool-component>
<div>foo<strong>hello</strong>bar</div>
</cool-component>

cool-component only knows that it is a referenced asCoolComponent. So what we can do is: hydrate(<CoolComponent />) . But we do not know what $children is. We are supposed to put a <strong>hello</strong> but where do you have this information in cool-component? So would you query for a strong tag on the root level, take it out, then use JSX.parseFromStrin(strongTagThatIfound) and then put it back in the render function? And then what about nesting? Mindfuck right? ⚡️

So our problem is: How would you know upfront which was the rendered child component of CoolComponenton the server when you only have the result HTML? You know by looking at it. But programmatically? The child information got lost when we did SSR. You could render the component without children and then make a diff to the one WITH but that sounds like an experiment with nitroglycerin 😶

Let’s go one step further and make child components required to identify as a custom element so that we know after SSR which was provided as a child:

Now we can query for .wc-comp to identify childNodes. However we must not use querySelectorAll since we only want the direct child to match and not the children children. The children children shall be processed by the children, not by this otherwise you would match back the wrong children to the wrong parent and start to shuffle around HTML. And this is where the issue starts again. What about if in we do not have a child with .wc-comp but in a nested one that does not belong to this instance we have?

Example:

const MyComp = 
<wc-without-own-children>
<a-sub-web-component>
{props.children}
<a-sub-web-component>
<wc-without-own-children>

So in fact the created <MyComp /> does take children. But it immediately passes them through to <a-sub-web-component>.a The child within props.children and marked with class="wc-comp" will be handled by the a-sub-web-component and should therefore be fetched from that reference. But the parent one will trigger first and will provide this props.children to MyComp which thankfully provides it at the right place rendering the correct markup but it would still let the sub component trigger itself to render within again. Double work, redundant processing.

So assume you can take it out, provide it back to the framework as an actual child.

Let’s recall isomorphism to our mind: React, Vue and other framworks do a 1:1 comparison to hydrate the component which totally makes sense. If the SSR DOM differs from the client DOM most frameworks bail and re-render.

Now it’s getting interesting since we are talking about cross-framework usage. Imagine that my-strong is a web component. It has its own lifecycle and can or cannot include a framework (it does not really matter here). It renders this:

<my-strong class="wc-comp">hello</my-strong>

to this

<my-strong class="wc-comp"><strong>hello</strong></my-strong>

A totally valid scenario in what we are trying to achieve. What we have given initially to the framework as a child in the render function was

<my-strong class="wc-comp">hello</my-strong>

So even if we would be able to properly identify the original childNode and extract it then in the next render cycle (whenever you change ANYTHING in the state of the parent component) the framework which renders the parent component would potentially find a diff since now it sees suddenly a strong tag that it does not know. It knows the outer <my-strong> but not the inner strong. At this point the render lifecycles went out-of-sync (in technical terms this is an unwanted side-effect).

In other words: The components don’t know anything from each other. They could always (e.g. by interactivity) change their state and therefore DOM individually. And suddenly components would go out of sync which is unfixable by the nature of the independency of those components.

Even if you come up with “I will just use a store that both share so their state is in sync” then you will notice that they will still go out-of-sync 💩. I’ll tell you why: The WebComponent layer in between (customElement ) is your interface to do changes from the point of view of the parent component so the parent component will never be able to tell why the INSIDE html of its own child suddenly has changed.

In a React-in-React World that works with a warning. React detects that it is being re-rendered in itself by a different renderer. But React does not know about other Frameworks! tldr: React is rocksolid when it comes to this “unsyncing” (at least of the Version 16 that I used).

As a recap side note: A negative impact with the nesting and html-copying is that sub-components could trigger themselves already to initialize — as the browser recognizes them (e.g. adding event listeners) and then be moved again (loosing their event listeners) only to be initalized again. So even if copying was a solution it would lead to performance loss. We do not want massively added complexity only to worsen our CPU load in the client.

As we have seen copying over initial HTML and then rendering with any dynamic framework is not an option to be considered because with the given approach we can neither properly identify the child components nor could we properly communicate to the parent component that a sub-component tree has changed.

Generically spoken the issue is with having “unknown” precompiled content there which we try to put in a “static” context but is in fact dynamic. We are trying to deal with browser-specific lifecycles (connectedCallback etc) within lifecycles of different frameworks and all of that potentially infinitely nested. That is a hard and bitter nut to crack.

We defined so many problems can we have some Solution Proposals?

Proposal 1 (trivial)

Do not handle children in your WebComponents.

By this logic you only have to deal with the top level rendering which should be quite alright since you would not need to identify the child components.

This proposal might be ok for a very simple site but for most of us this is not a solution as it is totally normal to have custom elements nested with children so this proposal seems as useless as trivial but maybe it fits for you.

Proposal 2 Shadow DOM

Let us put together the requirements again:

  • we want to render children that are already defined e.g. <my-wc> <children…> </my-wc>
  • we want to have multiple different frameworks
  • we want SSR
  • we want web components

This sheer amount of requirements can potentially be fulfilled with Shadow DOM. I personally favor not using Shadow DOM if possible but this is not about my opinion but finding a solution.

Shadow DOM inherits CustomElements from the same document which is good because it means that within scoped Shadow DOM we can use the customElements defined outside of it.

Let us have a closer look how it could elevate us to a solution.

Our first example is the implementation of a WebComponent that renders with React and has a child that we do not copy into the rendering function:

See the running sample here https://codesandbox.io/s/react-web-component-with-slot-jutdt.

All child nodes that sit in the customElement my-component are getting moved outside of the Shadow DOM before we start the framework render. They still sit in the my-component but now they are in the Light DOM. Since React only renders the native HTMLSlotElement it does not have a problem with needing to know the children, the browser takes care of that. They are there and they can be whatever they want (from SSR) because the React component stays isomorphic rendering only the <slot> tag.

This is actually nice first of all since it helps to keep consistency. The browser does not copy anything over to the slot element. It only references the element where the slot sits and acts like it was there.

If you check this.shadowRoot.innerHTML it will return: <div><slot></div></slot> . This is totally as expected since the slot is a PLACEHOLDER and the browser does not put the slotted element there. It just references it.

This being said the innerHTML stays consistent 😲🤩 . Just to recall that consistency is key for isomorphism.

Now let’s try to solve again the issues from above by using the approach of Shadow DOM.

Shadow DOM Nesting (no SSR)

Assume this is HTML is played by your CMS:

<wc-slider>
<wc-slider-elem>
<img src="test.jpg" />
</wc-slider-elem>
<wc-slider-elem>
<img src="test2.jpg" />
</wc-slider-elem>
</wc-slider>

Our previous analysis showed that all children that are given as a child to a customElement must act static and not change.

The framework expects consistency for the stuff it does not know. We can consider any atomic component with Shadow DOM slots to act static since the child nodes of the customElement do not unknowingly change — only the shadowRoot.innerHTML of a WebComponent child changes — but this cannot be seen from the outside/parent. If the slotted component is having children then the children should be slotted again within their Shadow DOM scope so they are outside of the scope of the framework rendering. So those act “static” since the same principles apply for them. #recursive #mindblown 🤓

Important Side Note: Vue uses the same element tag name for their template-processed children as the actual HTMLSlotElement so we cannot simply use <slot> since it would just render it empty in our case (“hey you did not provide me with Vue children so I just remove the slot for you”).

Vue actually deprecated slot for the attribute usage in favor of v-slot.

“The slot and slot-scope attributes will continue to be supported in all future 2.x releases, but are officially deprecated and will eventually be removed in Vue 3”

This change unfortunately does not fix the interference with the native <slot>
element.

Nesting with Slots without SSR seems rather easy. But first we need to find a way in Vue to render <slot> actually to the HTML so we can make use of it in the Shadow DOM.

I came up with this:

I set up an extensive but understandable sample of autonomous web components using Vue and React together: https://codesandbox.io/s/vue-shadowdom-native-slot-sample-with-consisten-dynamic-changes-with-react-and-vue-16brq .

The core of all of that is using slots together with this part of the code:

To achieve consistency we need our childNodes to be slotted but slotted elements live outside of the Shadow DOM in the Light DOM. So by calling this.appendChild after attaching the Shadow DOM we move the childNodes from Shadow DOM (this.shadowRoot ) to the root of the customElement(this ) before we trigger framework rendering.

In a more visualized manner the following happens:

The step after that is then to get something rendered inside the Shadow DOM that will render a HTMLSlotElement so that the childNodes that we moved out can be visible inside.

Here is the generic code snippet for Vue. Please note that Vue replaces the element it mounts on so that is why an additional “ghost” div is created:

And here the equal one for React:

Now this is awesome. Because even if we change the stuff within Light DOM the .innerHTML inside of the Shadow DOM where our framework component is rendered and lives stays consistent ! 🤩🤩 This is a huge achievement. Who would’ve thought that it can be just a few lines of code?

So far our first point is finally solved: Nesting with (different) frameworks including web components. Wow, that took a while.

But we didn’t solve SSR yet.

Shadow DOM with SSR + Nesting

Quick recap: The usage of WebComponents with a framework with its own Lifecycle (React, Vue, whatever…) nested into each other is something that simply seems impossible when not using Shadow DOM. And this statement would even be valid for just one framework that nests its Custom Elements into each other.

Now the problem is that on the server there is no such thing as Shadow DOM or CustomElement. So we cannot deliver something serverwise that is a Shadow DOM.

What we will have to accept is the fact that we require Shadow DOM and that Shadow DOM (by now) can not be implemented declaratively (maybe in the future we will have a shadowDOM attribute or even a <ShadowFragment> tag?).

Let us first take an unnested SSR sample:

<my-component>
<img src="test.jpg" />
</my-component>

Now let the required rendering result be

<my-component>
#shadowDom 🔽
<div>
<slot @ref={<img ..>}></slot>
</div>
#shadowDom 🔼
#lightDOM 🔽
<img src="test.jpg" />
#lightDOM 🔼
</my-component>

Now we need the SSR one to deliver exactly this result.

SSR Shadow DOM / Approach 1: trivial.

<my-component 
ssr="true"
shadowDomHtml="<...>"
lightDomHtml="<...>">
...
</my-component>

We can immediately trash this solution because you would get some pre-rendered HTML but you would not see it until the WebComponent is executed and moving it to the correct places. So you are doing something serverwise that only works when JavaScript is executed. This does not sound like at all something SSR is for. There is more issues with that solution but the given reason should be reason enough.

SSR Shadow DOM / Approach 2: hiding the slot.

Nested components have to be slotted (as described above) and are therefore NOT inside of the rendering path of the parent component but live outside of it (as per definition of slots).

This means you have components that need to be nested as siblings to their parent component. This sounds paradox but is totally normal for slots and expected as per the definition of a slot. But as long as the Shadow DOM is not yet attached (so JS not executed yet) the slots are not active. childNodes as siblings to the parent instead of being actual childNodes are technically and semantically wrong (think of your CSS styles). They are literally in the wrong place when being provided from the server as a sibling.

But we still get the upside that our HTML is pre-rendered. If we make sure that the wrongly placed slots are hidden then we might have the SSR advantage with comparably low disadvantages.

As you can see we are playing a slider parent element which inside of it has the .slider div and its “childNodes” .wc-slider as siblings. data-ssr helps us to identify that we should hydrate that component.

Now we need to extract the “slottables” for each WebComponent:

This works — for some use cases. But if you have multiple components nested then the most upper parent component would also select all of the slottable children which do not belong to it but to a slot in another child (in the end section “Additional Information” you will find the order of how WebComponents initialize). We can fix that by using a convention: Every customElement needs a wrapper with a specific className inside so we can use the .wrapperClass > [unslotted="true"] direct child selector.

So every component always looks like this when rendered:

<some-component>
<div class="some-component-wrapper">
...
<slot />
...
</div>
</some-component

Find a Demo implementation here: https://codesandbox.io/s/nested-shadowdom-ssr-slots-1kehq .

SSR Shadow DOM / Approach 3: render in-place not as sibling to achieve the best initial result

Approach 3 follows Approach 2 but renders the component exactly where it should semantically be— but not technically. It renders it exactly next to the slot tag so that it is in the “correct” DOM position for the correct visual appearance (CSS properly applies).

Then when JS is executed we add a Shadow DOM, move that DOM reference of the slotted child OUTSIDE of the Shadow DOM and we have the expected result.

Find the Demo using that approach here: https://codesandbox.io/s/nested-shadowdom-ssr-slots-step-2-improved-xuvwy . You can see that you do not see any flickering. Lovely ❤️

It would’ve been to easy if there is no pitfall on this one right? Yup. Isomorphism is given when a framework can render 1:1 the same client and serverwise. Which we do not in this example.

Let me explain you why. The server returns:

<wc-slider-elem>
<div class="slider-elem">
<slot></slot>

<div unslotted="true">
some text
</div>
</div>
</wc-slider-elem>

Our client code adapts it to have proper encapsulation and makes this:

<wc-slider-elem>
#shadowDom 🔽
<div class="slider-elem">
<slot></slot>
</div>
#shadowDom 🔼
#lightDom 🔽
<div unslotted="false">
some text
</div>
#lightDom 🔼
</wc-slider-elem>

Since our framework will render inside of the Shadow DOM the issue we would have with isomorphism would be that once we hydrate it would try to render this:

<wc-slider-elem>
#shadowDom 🔽
<div class="slider-elem">
<slot></slot>

<div unslotted="true">
some text
</div>
</div>
#shadowDom 🔼
#lightDom 🔽
<div unslotted="false">
some text
</div>
#lightDom 🔼
</wc-slider-elem>

So now suddenly the isomorphism becomes our issue. 🤓🤯

So our issue is that our Framework (Vue, React, whatever) would need to render something different on server than on client. So you would actually need to play DOM elements in different places depending if they are rendered on server or client.

Example:

🔼 The above is an example in a theoretical world. It kind of convolutes your code with a bit of dirty smell. How about we have the component equally rendered both SSR and CSR and for the required difference we use a postprocessor?

The next example shows that we use the same component for SSR as on client but we postprocess the child position before we send it to the client so that the “initial view/paint” looks correct — only to move it back to the Light DOM afterwards on the client:

It’s something! Isomorphism, check ✅ . But with this kind of Moving-DOM-around-postprocessing are also doing something that just does not feel clean.

A clean and working 90% solution 🤓

Check this example with Vue: https://codesandbox.io/s/brave-chatelet-pql3y. It has a step-by-step approach that could be the 90% solution. You will see an error in the console which we will clear out in the last 5% part of this article but don’t worry about it now. We also have to talk about the other 5%.

It all comes down to 3 crucial points:

  • Anonymous Framework components (as described above in this article)
  • Shadow DOM with a .module-className wrapper to detect direct [unslotted=”true”] children
  • Using <native-html-slot /> together with <framework-slot />
  • WebComponent to Framework component mapping

Prerequisites:

Every component needs to have an anonymous definition from which their respective Client/Server one inherits:

MyAnonymousComponent := 
<div>
<framework-slot />
<native-html-slot />
</div>;
MyClientComponent := <MyAnonymousComponent /> MySSRComponent :=
<my-component>
<MyAnonymousComponent>
... // potential nesting
</MyAnonymousComponent>
</my-component>

We have this requirement since we need to play the actual customElement tag serverside which we will not play on client side (remember infinite loop?).

Also on client-side we will never ever have actual framework children because our children are natively slotted.

That is also why framework-slot on the server will actually put HTML there (which we need for proper SSR) whereas native-html-slot only plays an actual HTMLSlotElement <slot> .

In my Vue example it looks like this:

<div>
<SlotFactory />
<slot></slot>
</div>

As of my version of Vue 2.6.11 <slot> is not the HTMLSlotElement but the Vue placeholder so I am using my SlotFactory Vue component to create an actual native slot which Vue does not treat as a Vue placeholder.

So we will use the above described system of having the attribute unslotted on our child elements and we use a proper className for each of our wrappers inside of our components. We attach the Shadow DOM and then we can find the direct child elements and move them out of the Shadow DOM to the Light DOM.

We are nearly there! 😯

But what is up with that Mapping that I mentioned?

In a generic, autonomous micro-frontend world I would send this to the SSR Service:

<my-component>
<whatever />
</my-component>

As my-component is a client only thing (customElement) the server would return our input immediately as output without any rendering changes because it simply does not know that component. Defining that component in Vue is not an option as I would then have to take care that it will never end up in the Client or we will not only break the isomorphism but have an infinite loop (we discussed this in detail already).

So I came up with this:

  1. HTML reaches SSR Service
  2. HTML runs through an XMLParser
  3. HTML Elements that are known as defined customElement are mapped back to their respective Vue.component on the server so Vue can render it and return it with my proper custom element tagName again (click on ssrService.js in my demo to see that)

So it literally comes all down to this mapping in my sample

Pitfall 1: Why a 90% solution? Where is the other 10%

This is the first Pitfall that I like to call the first 5%.
The above described solution solves child slotting if the slot is provided as a direct child of the WebComponent. If you add another div around it it will not find the direct child. So it might solve your use-case but it also might not.

So there is also ways around this issue. E.g. if you say that a slottable child within wc-slider MUST have the class .wc-slider-elem and that there cannot be another wc-slider or wc-slider-elem recursively nested then you could omit the direct child .wrapper > [unslotted=true] selection and instead search your slots via .wrapper .wc-slider-elem[unslotted=true] .
This works but it also makes your customElement very specific and it needs a lot of documentation and mental load since it is not a generic solution.

But I guess with enough documentation as much as: “This wc-slider can only contain wc-slider-elem components” it can work fine.

Pitfall 2: The other 5% missing due to textNode pitfall

There is a really weird (but logical) issue that will happen with the solution provided: This is one example of code that Vue rendered for me before my customElement initialized.

slot
#textNode(empty)
div.slottable
#textNode(empty)
comment

Visual to the user is only the following

div.slottable

When we attach the Shadow DOM and extract the slottableNode to be in the Light DOM then inside of the ShadowDOM it looks like this:

slot
#textNode(empty)
#textNode(empty)
comment

The Light DOM looks like this:

div.slottable

It doesn’t look like its a problem. But Vue complains to not be able to hydrate and bails the hydration in favor of re-render. If you dig deeper it becomes clear:

Client-Side Vue would render one textNode of two which is totally logic. But we just moved one component div.slottable outside and left the rest as it was delivered from server. That makes up two #textNodes that exist in the ShadowDOM whereas with the given architecture Vue will only find one consecutive text (since two texts next to each other are one textNode). So there is a mismatch of text nodes.

I will make this one quick: You will need to come up with something that finds adjacent text nodes and connects them as one text node within your customElement to have proper hydration.

Press enter or click to view image in full size

Conclusions / IMHO

This is how I felt after the analysis.

We want WebComponents for Autonomy, SSR for better UX and performance. Isomorphism is a must-have for SSR. By using WebComponents we add another lifecycle layer in between that is not natively built-in into the framework. If you setup some rules (see “Pitfall 1…”) and documentation you can make the customElements work together with SSR as described.

However: Do you want that?

It is very important to understand why the proposed Shadow DOM solution is considered major even though Shadow DOM does not exist on the server and why the others seem to not work consistently without having huge disadvantages and side-effects.

You need to ask yourself at this point if the overall added complexity on top of the anyway added complexity of SSR+CSR is worth the implementation. Also you should then come up with some conventions to make the whole process a bit more generic such as that you do not need to define a new customElement all the time manually.

Most probably there is a reason why Nuxt.js and Next.js exist but not AllFrameworksTogether.js (maybe I should develop that).

There were things we did not discuss in detail: What if we SSR a React component that contains a Vue component? And what if that Vue component outputs a React component which outputs a Vue component? So shall we just do ssrVue(ssrReact(ssrVue(ssrReact(...)))) or shall we create a recursion that stops when it does not find any matching WebComponents anymore? And how and when to load externalized libs? I am sure you can find way more problems but that’s it for now. I’m done with this — for now.

If you really want to head for this solution then I am proposing to implement this solution for just one framework of your choice and allow others to hook into the same mechanism clientside but not serverside. By that you enjoy the luxury of having your primary framework ssr’ed whilst still being able to just drop the WebComponents with other frameworks in there (which will CSR only).

So get your head around it and provide me with your opinion in the comments section.

Additional information

Not yet enough to read? There is some bits more to know. I analysed how and in which order initialize. It is worth to understand how the browser initializes custom elements.

Having the above triggers this :

logs >x-foo triggered
x-bar triggered
x-boo triggered
x-faa triggered

So it means that custom elements are initialized inside-out and top-to-bottom. Or tldr: Outside-In-Top-Down.

Now having this it means that the most outside custom element will trigger the initialization first.

This situation is bad considering consistency of nested frameworks (without using slots). Because if it was inside-out then the most outer element would be able to get all correctly inner-rendered child elements (assuming that they are immediately rendered). However outside-in (which is how the browser does it) means the exact opposite: First the most parent component gets to know the child structure and then afterwards the child structure changes -> out-of-sync.

You could try to overcome this inconsistency by building an orchestrator to overcome how the browser initializes but then again I am asking why would you even bother to use CustomElements at all if you are changing their behaviour in their core. Then I would rather “recommend” you to use a MutationObserver and build your own CustomElements than doing this weird side-effect-heavy workaround of orchestration.

Sources

--

--

ITNEXT
ITNEXT

No responses yet