How to run Svelte custom elements in dev mode
How to run Svelte custom elements in dev mode

Svelte makes it very easy to create custom elements (we already discussed it in a previous post actually).

By adding svelte:options tag to a component, you declare you want to obtain a custom element, and if you pass customElement: true to compiler options, you build a custom element that you can use in any web page/web app, like:

<my-counter />

The component is still usable as a regular Svelte component, and if you make a small demo app to test it, the most natural move is to import it in the main app component like that:

<script>
import Counter from './lib/Counter.svelte';
</script>

<Counter/>

In most cases, this is totally fine. But there is a difference between your demo page and the real usage of your custom element.

In the demo page, the component content is rendered in the main page DOM, whereas in the real usage, the custom element is encapsulated and its content is rendered in a shadow DOM.

Once again, this is probably fine in most use cases, unless:

  • You use CSS selectors like :host or :root.
  • You play with CSS variables on a pretty advanced level.
  • You are totally paranoid, and you want to make sure your demo page sticks to real usage at 100%.
  • You are not paranoid, but you want to make sure your demo page sticks to real usage at 100%, because when something can go wrong, it always goes wrong for you, and to be honest, it is a bit like the entire universe is trying to make you miserable constantly (but nonetheless, you are not paranoid).
  • None of the above, but you are just wondering, “Hey, what does it takes to run a custom element in Svelte dev mode?”.

Let’s be honest, it takes quite a bit of efforts, but it is also a fun opportunity to learn about many aspects of custom elements and Svelte ecosystem.

So let’s go!

Let’s try with a naive approach

First, turn your Counter component into a custom element:

./src/lib/Counter.svelte:

<svelte:options tag="my-counter" />
…

./vite.config.ts:

import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";

export default defineConfig({
  plugins: [
    svelte({
      include: ["src/**/*.svelte"],
      exclude: ["src/lib/Counter.svelte"],
    }),
    svelte({
      include: ["src/lib/Counter.svelte"],
      compilerOptions: {
        customElement: true,
      },
    }),
  ],
});

Then, use the custom element tag in your demo page:

./src/App.svelte:

<script lang="ts">
  import Counter from './lib/Counter.svelte';
</script>

<main>
  <my-counter />
</main>

And now run the demo app:

yarn dev

Yes, it does work, you can see your counter on the page.

Your counter component

What about automatic reloading?

But wait, if you change anything in your component nothing happens: you have to refresh the page to see the changes.

HMR (Hot Module Replacement) is still working, if you check your page console, you will see:

[vite] hot updated: /src/lib/Counter.svelte

But you will also notice something else:

DOMException: CustomElementRegistry.define: 'my-counter' has already been defined as a custom element

Here is the thing: whenever a change is done in the code, HMR kicks in and the custom element is redefined but, unfortunately, the Custom Element API does not allow to define a custom element several times under the same tag name.

To overcome this behavior, your only option is to patch the Custom Element API to allow multiple custom elements with the same tag name. Fortunately, redefine-custom-elements can do that for you.

Let’s just add it in your index.html:

<script src="//unpkg.com/redefine-custom-elements@0.1.2/lib/index.js"></script>

Ok now, CustomElementRegistry error is gone when you make a change, but even though HMR is working, the component is not refreshed. HMR would nicely refresh the component if it was <Counter>, but <my-counter> is not identified as an element that should be refreshed when ./src/lib/Counter.svelte changes…

That’s where you need to patch the patch. What you want is to re-render any element corresponding to a given custom element when this custom element is redefined.

To do so, you need to add this in index.html:

<script>
  const _define = customElements.define;
  customElements.define = function (name, CustomElement, options) {
    const nativeDef = _define.bind(customElements);
    nativeDef(name, CustomElement, options);

    // re-render the impacted elements
    setTimeout(() => {
      [...document.querySelectorAll(name)].forEach((el) => {
        const container = document.createElement("div");
        container.innerHTML = el.outerHTML;
        const newNode = container.firstElementChild;
        el.parentNode.replaceChild(newNode, el);
      });
    }, 0);
  };
</script>

It collects the impacted elements, copy their content in a new node and replace the existing one with this new version.

Now, the component is refreshed when you make a change. All is fine.


Wait, where are my attributes?

It is indeed fine for a super simple component like Counter, but what if your component has attributes?

Let’s add a message attribute on Counter:

./src/lib/Counter.svelte:

<script lang="ts">
  export let message = 'None';
  …
</script>
…
<div>{message}</div>

And let’s use it from App.svelte:

<my-counter message="Hello world!" />

Your counter component

Oh, something weird is happening! The message content is None (the default value), it should be Hello world!. And if you pay attention, you might see Hello world! appearing for few milliseconds and then being replaced by None.

Actually, if you just remove the patch you did in the previous step, Hello world! will be displayed.
So is there something wrong in this patch?

The patch is doing the following: when the custom element is redefined, it collects all the existing elements corresponding to this custom element, copy their outerHTML in a new <div>, and replace the old element with the child node of this <div>. What could possibly go wrong here?

Guess what? If you check your browser inspector, you will see your element outerHTML is:

<my-counter />

So, of course, when your patch duplicates it, the message attribute is not here.

What’s the hell? outerHTML should be:

<my-counter message="Hello world!" />

Well, for some reason (I would be really curious to understand why), Svelte removes the attributes from your custom element. Well, that was not expected…

When I hit this problem, I remembered that in the old days (yeah, I am a bit old in this business, just to give you an idea, I have implemented websites using <frameset>), HTML elements were not supposed to have random custom attributes. Custom attributes had to be prefixed with data-. What about giving it a try?

<my-counter data-message="Hello world!" />

This time, the attribute is preserved! Nice. But on the component side, you cannot declare an attribute with a data- prefix.

Ok, no big deal, let’s rename attributes on the fly in your patch!

./index.html:

const _define = customElements.define;
customElements.define = function (name, CustomElement, options) {
  // we create a wrapper to prefix/unprefix attributes with `data-`
  // this is needed because Svelte removes all regular attributes when rendering a custom element
  class CustomElementWrapper extends CustomElement {
    static get observedAttributes() {
      return (super.observedAttributes || []).map((attr) => `data-${attr}`);
    }

    attributeChangedCallback(attrName, oldValue, newValue) {
      super.attributeChangedCallback(
        attrName.replace("data-", ""),
        oldValue,
        newValue === "" ? true : newValue
      );
    }
  }
  // call the original define function passing the wrapper
  const nativeDef = _define.bind(customElements);
  nativeDef(name, CustomElementWrapper, options);

  // re-render the impacted elements
  setTimeout(() => {
    [...document.querySelectorAll(name)].forEach((el) => {
      const container = document.createElement("div");
      container.innerHTML = el.outerHTML;
      const newNode = container.firstElementChild;
      el.parentNode.replaceChild(newNode, el);
    });
  }, 0);
};

So now, not only you re-render the impacted elements, but you also prefix/unprefix attributes using an element wrapper class. Good.

And yes, your attribute is properly passed to the component, and yes you see Hello world! appearing, and yes it does change automatically if you change the source code. Good, good, good.

Your counter component

At this point, there is two kind of people:

  • the ones who think it was hard enough, and from now on, everything should work fine and as expected,
  • the ones who think that considering the amount of unexpected problems you have been facing so far, the odds are high that more problems are going to happen in whatever comes next.

If you belong to the first category of people, prepare to be disappointed, and honestly if you are not good at managing frustration, I respectfully suggest you to stop reading this post now, because we are now going in a dark place.

Style will apply in child components, will it?

Let’s say you want to display the message with a dedicated component:

src/lib/Badge.svelte:

<script lang="ts">
  export let message = '';
</script>

<div class="box"><small>{message}</small></div>

<style>
  .box {
    padding: 0.3em;
    margin: 0.3em;
    background-color: #c0c0c0;
  }
</style>

src/lib/Counter.svelte:

<script lang="ts">
  import Badge from './Badge.svelte';
  …
</script>

…
<Badge {message} />

Surprise! The Badge component is rendered but unstyled!

Your counter component

If you inspect your page, you will noticed the Counter component has its style injected in its Shadow DOM in a <style> tag generated by Svelte. But this is not the case for the Badge component. Actually the Badge component style is injected too, but in the main DOM (check the main <head> content in your inspector, you will see the <style> tag containing your .box class), not in the Shadow DOM.

Why? Because Badge is not a custom element, so Svelte just processes it as any regular component and injects its style in the main DOM even though it is inside a custom element.

You cannot make Badge a custom element (it would had its own Shadow DOM, it would be super messy). Your only option is to make sure that all the CSS needed in Counter is injected in its Shadow DOM.

Ok so, let’s fix this.

First, let’s put your Badge style in a separate .scss file:

./src/lib/Badge.scss:

.box {
  padding: 0.3em;
  margin: 0.3em;
  background-color: #c0c0c0;
}

And let’s import it in the main style file, ./src/lib/Counter.scss:

@import './Badge.scss';

button {
  …
}

The Badge component style is not loaded directly in Badge.svelte (that would be useless, as it would be injected in the main DOM anyway). Instead Badge.scss is imported in the Counter component style, so you have all the style needed for your custom element in there.
Good, let’s import it in Counter:

./src/lib/Counter.svelte:

<style lang="scss" src="./Counter.scss"></style>
…

And voilà, the Badge component is now… UNSTYLED!!!!????

Come on!!! It should work, Counter.scss has the .box class, what could be wrong?

Just check the Vite’s console, you will see the following:

src/lib/Counter.svelte:16:40 Unused CSS selector ".box"

Oh no! The .box class is not used in the Counter component (it is used in the Badge component), so Svelte detects it as unused and removes it from the final CSS!

That’s a smart feature of Svelte for sure (and I really love it), but right now it would be so cool to disable it, right? Well you can’t, sorry.

It is a bit frustrating, right? But will you just give up here? No, no way, not now. If we have to do it the hard way, we will do it the hard way, okay?

Let’s go.

First, remove

<style lang="scss" src="./Counter.scss"></style>

as it is not behaving as you want.

Now let’s manage CSS injection manually in Counter Shadow DOM:

./src/lib/Counter.svelte:

<script lang="ts">
  import css from './Counter.scss';
  import { onMount } from 'svelte/internal';
  …

  let elem;
  onMount(() => setStyle());

  export const setStyle = () => {
    // inject css in shadow root manually (we done using <style>, Svelte removes unsed css selectors)
    const root = elem?.getRootNode();
    if (root) {
      root.querySelectorAll('style').forEach((style) => root.removeChild(style));
      const style = document.createElement('style');
      style.appendChild(document.createTextNode(css));
      root.appendChild(style);
    }
  };
  …
</script>

What is happening here? You import the CSS content from Counter.scss, you get the parent Shadow DOM root, and you create a new <style> tag with the expected CSS.
And to make sure you do not have trouble when the component is refreshed, you just remove any previously existing <style> tag.

Let’s try that.

YES!! It works! The Badge component is styled!!!

Your counter component

Oh, that was a tricky one, but look at that, you have a custom element, it can take attributes, it uses child components, everything is properly styled, and hot reload is fully working, like for example if you change the background color of the .box class to blue, you get it bl… GREY???!!!

(Okay, that’s fine, we will figure it out, take a deep breath, and just consider this: beside all the super cool technics you have learnt so far, it is also a fantastic opportunity to practice your patience.)

Right, so here is the thing: now the dependency between the style and the Counter component is unclear to Svelte, and when the style is updated, HMR does not update the component anymore.

What you need to do is to tell Vite.js that when any SCSS files change, it has to update the Counter component.
To do that, you need to write a custom plugin in ./vite.config.js:

…
export default defineConfig({
  plugins: [
    …
    {
      name: 'hmr-scss',
      handleHotUpdate({ file, server, timestamp }) {
        if (file.endsWith('.scss')) {
          server.ws.send({
            type: 'update',
            updates: [
              {
                acceptedPath: '/src/lib/Counter.svelte',
                path: '/src/lib/Counter.svelte',
                timestamp: timestamp,
                type: 'js-update',
              },
            ],
          });
        }
      },
    },
  ],
});

And that’s it! Finally, it works, the full thing is now working!

Your counter component

 

 

Conclusion

That was epic, and maybe it was not worth all these efforts just to run a custom element in a demo page, but look all what you have learnt over this bumpy ride:

  • How to patch the Custom Element API
  • How to make a wrapper class on a custom element to change attribute names on the fly
  • How to manage style injection manually to by-pass Svelte unused CSS rule removal
  • How to create a Vite.js plugin to customize HMR behavior

And as a reward, if you are a Svelte aficionado, I suggest you to play with our search widget (implemented as a custom element using Svelte, who would have guess?), in this demo page we have indexed all the Svelte Summit 2020 and 2021 videos.

Note: you can get the full example code discussed in this article here.

Related articles

Semantic search and unstructured data made easy
An approach to index and make any data findable. In your email, when you look for information in your favorite search engine, when you are looking for a video, when...
How to make the content of your videos searchable
Talks, conferences, meetings, lessons, how-to-tutorials, teaching lessons, product reviews, webinars… Video is absolutely everywhere. How can you make all these videos accessible and searchable? How can you index their content,...
Publishing a file in 2022
When you're a developer, everyday is an adventure. Let me tell you about something that seemed really simple initially and turned out to be surpringly tricky... My initial need: displaying...

Nuclia’s latest articles and updates, right in your inbox

Pick up the topics you are the most interested in, we take care of the rest!