I’ve recently fallen into a SSR shaped hole, and I just can’t seem to get out. I am having lots of fun down here, however. Over the past weeks, I've been trying out the new release of Astro SSR, which I wrote about extensively here. Today though, I'll be talking specifically about server rendering Custom Elements; the last bastion of the web components nay-sayer.
As linked above, I'm currently working on an Astro site. This website is mostly static, but has some nice dynamic routing, and some interactive bits and pieces here and there. Loading all of React on the page for these bits of interactivity sprinkled throughout my site seems like a bit overkill, so I figured this is a good excuse to try out Astro's Lit integration. I found however, that some of these components had such little interactivity, that even Lit (which is already an extremely tiny library) seemed overkill. So, I figured, why not just some vanilla custom elements?
I refactored my Lit components to be vanilla (or: native) HTMLElements, restarted my local Astro development server and... Ran into problems.
ReferenceError: document is not defined
Shimming the DOM
Considering that LitElement down the line just extends from HTMLElement, I was hoping to be able to reuse the Lit integration to SSR vanilla custom elements, but unfortunately that didn't work. The reason for that is, in order to be able to render custom elements on the server, we need some browser API's to be available on the server, so these API's have to be shimmed in our Nodejs environment. Lit, however, makes surprisingly little use of browser APIs to be able to do efficient rendering. This means that the DOM shim that Lit SSR requires is really, really minimal, and doesn't include a bunch of things, like for example querySelectors. This sadly also means that Lit's minimal DOM shim will not suffice for rendering native custom elements. Unlucky.
So I set out to find a different solution, and was pointed to linkedom. Linkedom is an excellent package that allows us to shim DOM apis on the server, and has decent support for custom elements. There are some limitations however, like for example there is no implementation of the HTMLSlotElements assignedNodes()
method, and unfortunately the maintainer is a little bit weird about it. With linkedom ready to shim the required APIs in a Nodejs environment, I was able to create an Astro vanilla custom element integration using @lit-labs/ssr's ElementRenderer
interface; but adjusted for vanilla custom elements.
custom-element-ssr
I've wrapped this all up nicely in a new package: custom-elements-ssr, that you can install like so:
npm i -S custom-elements-ssr
custom-elements-ssr
comes with two integrations:
Astro integration
Now that I have my vanilla custom element integration, I can add it to my Astro config, and write some custom elements:
astro.config.mjs
:
import { defineConfig } from 'astro/config';
import netlify from '@astrojs/netlify';
+ import customElements from 'custom-elements-ssr/astro.js';
export default defineConfig({
site: 'https://my-astro-course.netlify.app',
adapter: netlify(),
+ integrations: [customElements()],
});
@lit-labs/ssr
compatible ElementRenderer
The project is also compatible with @lit-labs/ssr
. You can import the CustomElementRenderer
itself like so:
import { CustomElementRenderer } from 'custom-elements-ssr/CustomElementRenderer.js';
Adding interactivity
Finally, we get to add some interactivity to our pages!
Really, all my component's interactivity comes down to this:
connectedCallback() {
// initial render
this.render();
this.addEventListener('change', (e) => {
const buttons = this.querySelectorAll('input');
const answer = parseInt(this.getAttribute('answer'));
this.answered = e.target === buttons[answer];
this.render();
this.dispatchEvent(new CustomEvent('question-answered', {composed: true, bubbles: true}));
});
}
Not much, huh?
Whenever someone answers a quiz question, this.render()
updates the component, and shows the user whether or not they've answered correctly.
Note how when the page renders, the quiz questions are already visible; but the JS comes in slightly later
When all questions on the page have been answered, I display another custom element which links to the next page. This custom element is initially hidden, and hydrated on client:idle
, because it requires the user to have answered all the questions anyway; so we don't need to load the JS eagerly.
<quiz-next-card nextLink={nextLink} next={next} client:idle>
<p>🎉 You've answered all questions correctly. You can now move on to the next section.</p>
</quiz-next-card>
Taking things further
As I was working on this, I realized how nicely this all ties in with another project I maintain; generic-components. Generic-components is a library of accessible, vanilla custom elements that I've been using all over hobby projects for a long time now, and never have to rebuild from scratch in whatever new framework.
A problem that I've occasionally run into with these components, however, is the "Flash of Unupgraded Custom Element". Take for example the <generic-tabs>
component:
<html>
<body>
<generic-tabs selected="1" label="Info">
<button slot="tab">About</button>
<button slot="tab">Contact</button>
<div slot="panel">Lorem ipsum dolor sit amet, consectetur adipiscing elit</div>
<div slot="panel">Sed ut perspiciatis unde omnis iste natus error sit voluptatem</div>
</generic-tabs>
<script type="module" src="https://unpkg.com/@generic-components/tabs.js"></script>
</body>
</html>
What happens here is the following:
- The user loads the page
- The page renders the HTML
- The page loads the JavaScript
- The JavaScript upgrades the
<generic-tabs>
component - Which then takes care of adding the interactivity, tab behavior, and displaying only the currently selected panel
So in between the page rendering, the javascript loading, and the custom element upgrading, we have a flash of unupgraded custom element. Sometimes people fix this by adding the following CSS snippet:
generic-tabs:not(:defined) {
display: none;
}
Which is a clever little trick to simply hide the custom element until the JS has loaded, but... then the user won't see the custom element at all until the JS has loaded 🙃
Server side rendering seems like a good fix for this; we can already render the initial state of the custom element on the server, and then add the interactivity/tab behaviors on the client when JS has loaded, but completely avoid the flash of unupgraded custom element.
Note how the tabs component is immediately visible on the page, while the JS to add interactivity comes in slightly after
You could argue that the tabs will be un-interactive until the JS has loaded, which indeed is true, but the tabs component is unlikely to be interacted with immediately by the user, so in this case it's OK. However, like Matthew pointed out on twitter, you could even take it a step further and display the buttons as being disabled, so the user is aware that they're not interactive just yet:
generic-tabs:not(:defined) button {
background-color: lightgrey;
color: darkgrey;
}
This way we avoid the flash of unupgraded custom element, we display something to the user immediately, yet it's still clear to the user the element is not quite ready for interaction just yet.
This becomes even more apparent when we apply it to the showcase app. The current showcase app makes use of the :not(:defined)
pattern, and thus won't show any of the components until the JS has loaded:
And here's what it looks like when we apply server side rendering, as well as 'upgrading' the styles once the custom elements have loaded:
The loading of this page/JS has been artificially slowed down for demonstration purposes
Note how the HTML and components are displayed immediately, and as the JS trickles in, components become interactive.
Wrapping up
As I said at the start of this post, it's been pretty exciting to play around with server rendered custom elements. If you're interested in trying it out as well, you can find the the custom-elements-ssr
package here. Its pretty experimental still, but please let me know if you find any mistakes, and we can fix them together 🙂.
You can also take a look at a working example project, or a live demo.
Top comments (4)
This is great work. I love seeing this explored. Pretty soon we won't need to choose a non-custom-element framework to our desired benefits, and choosing non-custom-element frameworks may become less ideal because they aren't hooked into the browser's devtools out of the box (requiring browser extensions).
Hey Pascal, it would be valuable to get some of your thoughts here:
github.com/withastro/roadmap/discu...
This solution appears to no longer work... I tried installing it (on an existing project, and also from scratch only installing your package to a bare bones project), but kept getting "str.replace is not a function" (or else this.buffers[0].slice is not a function) errors. I think the custom element tags are getting turned into custom objects by Astro. Not sure (it's pretty hard to debug), but I couldn't get it to work after some effort. Just FYI.
I did some digging (with Astro 5.0) but don't have a PR ready yet:
<CustomTag />
) if you use the kebab case html tag, you get the tag name as a string (<custom-tag></custom-tag><script>import "@components/custom-tag></script>
)str.replace is not a function
is coming from github.com/thepassle/custom-elemen... The attributes from astro are booleans while escapeHtml expects stringsthis.buffers[0].slice
is a problem with the rendering of children: github.com/thepassle/custom-elemen... Empty children get passed in as empty object{}