DEV Community

Pascal Schilp
Pascal Schilp

Posted on • Edited on

Server Side Rendering Vanilla Custom elements in Astro

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


Enter fullscreen mode Exit fullscreen mode

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()],
});



Enter fullscreen mode Exit fullscreen mode

@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';


Enter fullscreen mode Exit fullscreen mode

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}));
    });
  }


Enter fullscreen mode Exit fullscreen mode

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.

quiz

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>


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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

flash

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;
}


Enter fullscreen mode Exit fullscreen mode

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.
ssr tabs

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;
}


Enter fullscreen mode Exit fullscreen mode

not defined

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:

showcase not defined

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:

showcase ssr

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)

Collapse
 
trusktr profile image
Joe Pea

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).

Collapse
 
trusktr profile image
Joe Pea • Edited

Hey Pascal, it would be valuable to get some of your thoughts here:

github.com/withastro/roadmap/discu...

Collapse
 
midnight_rambler profile image
Chris S • Edited

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.

Collapse
 
ascholz profile image
Alexander • Edited

I did some digging (with Astro 5.0) but don't have a PR ready yet:

  1. custom element as objects: this is if you include the tags as Astro components (<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>)
  2. str.replace is not a function is coming from github.com/thepassle/custom-elemen... The attributes from astro are booleans while escapeHtml expects strings
  3. this.buffers[0].slice is a problem with the rendering of children: github.com/thepassle/custom-elemen... Empty children get passed in as empty object {}