I recently looked at Svelte as I'd been hearing a lot about it and was curious to see how it measured up against Vue, which I use for pretty much all my frontend work. In particular, I was curious if it would be a better fit for building web components, as it seemed to have some major advantages compared to Vue. I decided to write this article because I think Svelte could be the perfect framework for building web components, but currently there are some things which are holding it back, and if you are considering using it, you should probably know about them, and the possible workarounds before you make your decision about which framework to use.
In theory, all you need to do to convert your existing Svelte app into a web component, is add the
<svelte:options tag="component-name">
somewhere in your .svelte file with whatever you want that
component to be named, and enable the customElement: true
option in the compiler. That's it! Once
that compiles down, all you need to do is include the built bundle, and you can start using your
shiny new web component.
That looks way too easy, so where do the problems start? Well, as usual, the "Hello World" works as expected, but as soon as you try to do anything else, you start running into issues. I will list all the issues I have encountered so far here, including any workarounds and I will try to link to the relevant Github issues where I can.
You must tag every component you use!
https://github.com/sveltejs/svelte/issues/3594
So you've made it past the "Hello World" web component, and want to start making something a bit more advanced, and you've created another svelte component. You don't want to export it as a web component, you just want to use it inside your main component, but that won't work.
// App.svelte\
<svelte:options tag="my-awesome-component" /><script>
import OtherComponent from './OtherComponent.svelte';
</script><OtherComponent />
And in a separate file we have:
// OtherComponent.svelte
<p>Hello World!</p>
Builds fine, but when you try to use it:
TypeError: new.target does not define a custom element
The way you can fix this is to add <svelte:options tag="some-name">
to every component that you
use. This forces you to create a web component out of every single svelte element that you use in
your code.
Okay, seems like we can live with that... We just add a tag name that we never use. While this is fine for our own components, try to use any svelte component library. They don't have a tag name for every single component, so you can't use it. Shame :(
Update: There is a workaround mentioned here: https://github.com/svelte-society/recipes-mvp/issues/41#issue-638005462
It works by using a naming convention to differentiate between components that we want to use as web
components, and regular svelte components which we just want to use internally. In the example, any
component eding in .wc.svelte
will be compiled with the customElement
option turned on, and the
rest will have it turned off.
// rollup.config.js\
svelte({ customElement: true, include: /\.wc\.svelte$/ }),\
svelte({ customElement: false, exclude: /\.wc\.svelte$/
}),
No CSS in nested components, no global CSS either
Thats right, this is the big one. All the examples you see of people using Svelte for web components will either have a single Svelte component, or will not use css at all. Take a look below, our main component's css will work, but will not be scoped, which means it will leak through to all child components.
// App.svelte\
<svelte:options tag="my-awesome-component" /><script>\
import OtherComponent from './OtherComponent.svelte';\
</script><style>\
p {\
background-color: red; // Yes, I work... but everywhere!\
}\
</style><p>I'm red!</p><OtherComponent />
And in a separate file we have:
// OtherComponent.svelte
<style>
p {
background-color: blue; // useless\
}
</style><p>I'm red too!</p>
So, if scoping doesn't work, can we just use the :global(p)
to style all p
tags? Nope, the
global modifier won't work inside a web component. https://github.com/sveltejs/svelte/issues/2969
Hmm, but it seems like all css from inside our main component is leaking through to the children components,
because scoping doesn't work. Can't we just put all our css in there? Well, not really. If we remove
the p
tag from App.svelte
we lose styling in our child component as well. Any "unused" css will be
stripped out when compiling, and since the compiler correctly sees that we have no p
tags in App.svelte
, it throws out that bit of css.
So whats the work-around? The best thing I can come up with is to find a way to disable pruning of unused css, and relying on the fact that scoping doesn't work. Not very nice, but its the best we've got. I'm still trying to find out how to actually achieve this, so if you know how, drop a comment and I'll update the article.
Props are not immediately populated
https://github.com/sveltejs/svelte/issues/2227
When working with Svelte components, we are used to having props populated by the time the component renders. If a prop has a value, its already there and ready for us to use. If not set, we can optionally specify a default value or leave it undefined. Lets take a look at how this works with custom elements.
// App.svelte\
<svelte:options tag="my-awesome-component" /><script>\
export let myProp = "some default value";\
</script>My prop is: {myProp}!
Before we even dive into, we will need to make a change, as camel casing props won't work with web
components due to a bug. We need to rename our prop to myprop
so that we can set it from an
attribute.
https://github.com/sveltejs/svelte/issues/3852>
// App.svelte\
<svelte:options tag="my-awesome-component" /><script>\
export let myprop = "some default value";
$: console.log(myprop); // console.log() every time myprop changes\
</script>My prop is: {myProp}!
Then we can try it out in an HTML page. If we don't specify the myprop
attribute, it works as
expected, and takes the default value. But if we specify something for the attribute like so:
// foo.html\
...\
<my-awesome-component myprop="Foo" />
...
Lets look at our console output:
#: some default value (App.svelte:4)\
#: Foo (App.svelte:4)
Even though we have specified a value for our prop, it uses the default value when it renders the element, and only later on does it change it to the attribute value, as if it was only changed at a later time. This can cause all sorts of problems, but luckily there is a way we can get around it, although its not pretty.
// App.svelte
<svelte:options tag="my-awesome-component" /><script>
import { onMount, tick } from 'svelte'; export let myprop = "some default value"; onMount(async () =>
{
console.log(myprop); // "some default value"\
await tick();
console.log(myprop); // "Foo"\
});\
</script>My prop is: {myProp}!
After awaiting tick()
we can be sure that all properties have the correct values, and at this
point we can maybe set a variable ready
which we can use to optionally render the page, or do any
other logic. Its an extra layer of unneeded logic, but it solves the problem until there is a proper
fix.
Slots and $$slots
https://github.com/sveltejs/svelte/issues/5594>
UPDATE: this is now fixed in 3.29.5, so *$$slots*
should work again as normal.
Slots are a useful way to allow customisation of our web component. We can use them to allow
embedding HTML content within certain sections of our component, which we define. There is a small
gotcha, though. The $$slots
added in
svelte 3.25.0 does not work on
the top level of a web component. So although the slots are rendered correctly, we can't check which
slots are actually being used. This would be useful in cases where we might not want to render a
certain portion of our component if a slot is not being used. Look at the following example, which
would work fine, but when used on a web component $$slots
is always {}
.
// App.svelte\
<svelte:options tag="my-awesome-component" /><p>I am always rendered</p>{#if $$slots.default}\
<p>
I only want to be displayed if the slot is being used. <br />
<slot></slot>
</p>
{/if}
Can we find a workaround? Lets make use of the
HTMLSlotElement to make our own
version of $$slots
. If we get a list of all slots in our web component, we can use
assignedNodes to
check if there are any nodes assigned to our slots. Our default content will not show up here, only
content from outside.
The assignedNodes()
property of the
[HTMLSlotElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLSlotElement)
interface
returns a sequence of the nodes assigned to this slot, and if the flatten
option is set to true
,
the assigned nodes of any other slots that are descendants of this slot. If no assigned nodes are
found, it returns the slot's fallback content.
Then we simply count the nodes to see if the slot is being used or not. We can use this to prepare a
boolean map that mimics $$slots
. Now all that's left is to keep up to date with any changes in the
DOM. We can listen for slot changes to respond to any slots being used or unused.
The slotchange
event is fired on an
[HTMLSlotElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLSlotElement)
instance
([<slot>](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot)
element) when the
node(s) contained in that slot change.
Since this event fires for any change, we add a condition to check if the number of nodes has changed, and only update if we need to. Notice how we use the spread syntax to trigger reactivity.
// App.svelte\
<svelte:options tag="my-awesome-component" /><script>\
import { onMount } from 'svelte';\
import { get_current_component } from "svelte/internal";const thisComponent = get_current_component();\
let slots = {}; // we can use this instead of $$slots
onMount(async () => {\
// get a list of all slots in our webcomponent\
const elements = [\ ...thisComponent.shadowRoot.querySelectorAll('slot')\ ]; // create our
$$
slots replacement\
slots = Object.fromEntries(\
elements.map(e => ([e.name, !!e.assignedNodes().length]))\
); // keep up to date with changes\
elements.forEach(slot => slot.addEventListener(\
'slotchange', () => {\
// only update if we need to\
if (slots[slot.name] !== !!slot.assignedNodes().length) {\
slots = {...slots, [slot.name]: !!slot.assignedNodes().length};\
}\
}\
));\
});</script>{#if slots.mySlot}\
Only display me, if slot is used <br />\
{/if}\
<slot name="mySlot">Some default content</slot>
Does it work? Yes! Are we done? I wish :( Here comes the edge case... Lets take a look at this example. Here we place our slot inside of the if block.
{#if slots.mySlot}\
Only display me, if slot is used <br />\
<slot name="mySlot">Some default content</slot>\
{/if}
While this would work with $$slots
, our version doesn't. What happens is our slot doesn't get
rendered, because of the condition initially being false. Because of this when we call
.shadowRoot.querySelectorAll('slot')
it gives us an empty node list. So we are left with either
hiding the content with css instead of the if block, or finding some other way to list all props in
our component. Unfortunately I know of no such way, so for now its css.
Events: work if you do them right
https://github.com/sveltejs/svelte/issues/3119
Being able to send out events from your web component is no doubt a crucial part of being able to communicate with the "outside world". Has the user clicked a button? Can we send an event out? Absolutely, lets see how we can do it.
Basically what we need to do is create a
CustomEvent and dispatch it from our
top level element. Looking at the docs, we can see we need to set composed
to be able to cross the
shadow DOM boundary.
The read-only composed
property of the
[Event](https://developer.mozilla.org/en-US/docs/Web/API/Event)
interface returns a
[Boolean](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Boolean)
which indicates whether or not the event will propagate across the shadow DOM boundary into the
standard DOM.
Lets see how this looks in our Svelte component.
// App.svelte\
<svelte:options tag="my-awesome-component" /><script>\
import { get_current_component } from "svelte/internal";
const thisComponent = get_current_component(); // example function for dispatching events\
const dispatchWcEvent = (name, detail) => {
thisComponent.dispatchEvent(new CustomEvent(name, {
detail,
composed: true, // propagate across the shadow DOM\
}));
};</script><button on:click={() => dispatchWcEvent("foo", "bar")}>
Click me to dispatch a "foo" event!
</button>
Then in our HTML we can do something like this:
// foo.html\
...
<script>
window.addEventListener('DOMContentLoaded', () => {
document.getElementById("foobar").addEventListener("foo", e => {
alert(`Got foo event with detail: ${e.detail}`);
});
});
</script>
...
<my-awesome-component id="foobar"/>
...