28
Svelte for Web Components development: Pitfalls and workarounds
Since Svelte is a library in relatively early stage, there are some pitfalls to avoid with workarounds, which I'm going to describe in this article.
Every props
defined in Svelte components compiles to an attribute of a custom element. In HTML, most of the attributes are named in kebab-case
, specified as words in lower alphabets combined with -
1.
In Svelte, however, props
are described as a set of declaration of variables, which in JavaScript cannot include -
in the name. This is known issues2 with a workaround.
Svelte team recognizes this but has not been resolved. It is suggested to use $$props
to access the props like $$props['kebab-attr']
in these situations2.
This, however, works only in the case you use the custom element in HTML directly. It is okay for the end users of the custom element since they would use it in that way but is problematic for developers of the components. If you mount it as Svelte component, all props
should be undefined
at that moment the component has been instantiated, unintentionally.
// App.svelte
<script>
import './Kebab.svelte'
let name = value
</script>
<input bind:value>
<swc-kebab your-name={name}></swc-kebab>
// Kebab.svelte
<svelte:options tag="swc-kebab" />
<script>
export let yourName = $$props['your-name']
</script>
Hello, {yourName}
Another workaround which allows you to code <swc-kebab your-name={name}></swc-kebab>
is to have a wrapper class to intercept default behavior of the Svelte3:
// KebabFixed.js
import Kebab from './Kebab.svelte'
class KebabFixed extends Kebab {
static get observedAttributes() {
return (super.observedAttributes || []).map(attr => attr.replace(/([a-zA-Z])(?=[A-Z])/g, '$1-').toLowerCase());
}
attributeChangedCallback(attrName, oldValue, newValue) {
attrName = attrName.replace(/-([a-z])/g, (_, up) => up.toUpperCase());
super.attributeChangedCallback(attrName, oldValue, newValue);
}
}
customElements.define('swc-kebab-fixed', KebabFixed);
// App.svelte
<script>
import './KebabFixed.svelte'
let name = value
</script>
<input bind:value>
<swc-kebab-fixed your-name={name}></swc-kebab-fixed>
Similarly, you cannot use an upper-case letter in the name of attributes if the component is mounted as a custom element. For instance, even you specified like yourName="some value"
, it will be converted to a lower-case version like yourname
.
It seems the browsers that convert names to comply the naming convention explained above, rather than a problem of Svelte's Web Components support.
Since camelCase is de-facto standard way of naming in JavaScript, naming a prop like yourName
as usual would result undefined
.
In this case, changing two occurrence of yourName
to yourname
fixes it to work properly. Unlikely, the attribute name on caller side doesn't matter, whichever it is yourName="camelCase"
or yourname="non camel case"
.
// App.svelte
<script>
import './NoUppercase.svelte'
let name = value
</script>
<input bind:value>
<swc-no-uppercase yourName={name}></swc-no-uppercase>
// NoUppercase.svelte
<svelte:options tag="swc-no-uppercase" />
<script>
export let yourName // Change this to `yourname`
</script>
Hello, {yourName} <!-- Change this to `yourname` -->
In the example above, I have used Svelte notations to set attribute values. You can leverage the most of Svelte functionality to develop custom elements. Changes of value
propagates to name
in the child component which depends to value
.
Svelte notation does not available in HTML, so you wouldn't be able to yourname={name}
. The only way to set attribute values is to code yourname="a string literal"
directly. Use DOM APIs to change these attribute values dynamically:
const element = document.querySelector('swc-child')
element.yourName = 'a updated name'
Whenever attribute values changed, attributeChangedCallback
which Svelte registered propagates the change to the internal DOM of the custom element. This enables you to treat the custom element similarly to Svelte components.
On the other hand, there's no support of bind:
mechanism in custom elements. Changes in child custom elements will not be available to parent components.
Use custom events I'd described later to pass back the changes in child custom elements. In this case, end users of the custom element must register an event listener to subscribe the events.
This weighs to the end users, but it is reasonable for them to be responsible of since they've decided not to use any front-end frameworks.
Svelte components accept any objects as contents of props
. But attribute values in HTML accept just a literal string.
If you have a Svelte component first and try to compile it to a custom element, this might be a problem. You can serialize an object to JSON if the object is simple enough, while it is very unlikely in the real world.
A (weird) workaround would be to have an object like "store" in global namespace, pass any objects you want through the store. As long as the key is just a string, you can set it to the attribute values of the custom element.
// App.svelte
<svelte:options tag="swc-root" />
<script>
import PassAnObjectFixed from './PassAnObjectFixed.svelte'
let name = 'default name'
window.__myData = {
'somekey': {}
}
$: window.__myData['somekey'].name = name
const syncToParent = () => {
name = window.__myData['somekey'].name
}
</script>
<input bind:value={name}>
{name}
<p>As WC: <swc-pass-object name={data}></swc-pass-object></p>
<p>As Svelte: <PassAnObject {data} /></p>
<p>As WC: <swc-pass-object-fixed key="somekey"></swc-pass-object-fixed><button on:click={syncToParent}>Sync to input field</button></p>
// PassAnObjectFixed.svelte
<svelte:options tag="swc-pass-object-fixed" />
<script>
export let key
let name
const refresh = () => {
name = window.__myData['somekey'].name
}
refresh()
$: window.__myData['somekey'].name = name
</script>
Hello, {name} <button on:click={refresh}>Refresh</button>
<input bind:value={name}>
This way, the parent component can read the changes the child applied to store, thus you can have some feedback mechanism like the bind:
in anyway.
Of course it is not very cool since only the key would be specified explicitly. I'd prefer to change the values through DOM API and custom events to have dependency of data clear.
Svelte supports custom events to emit any component specific events other than built-in events like on:click
, on:keydown
or on:focus
.
However, a callback set via addEventListener
wouldn't be able to catch them since they're built on Svelte-specific event mechanism. In the example below, you can see how a custom event, which is successfully listened in Svelte event handler, doesn't fire the callback registered via addEventListener
.
// App.svelte
<svelte:options tag="swc-root" />
<svelte:window on:load={() => handleLoad()} />
import CustomEventExample from './CustomEventExample.svelte'
let name = 'default name'
const handleCustomEvent = (event) => name = event.detail.name
let rootElement
const handleLoad = () => {
const customElement = rootElement.querySelector('swc-custom-events')
customElement.addEventListener('namechanged', handleCustomEvent)
}
$: if (customEventElement) customEventElement.name = name
</script>
<div bind:this={rootElement}>
<h1>Custom Event</h1>
<p>As Svelte: <CustomEventExample {name} on:namechanged={handleCustomEvent} /></p>
<p>As WC: <swc-custom-events name={name}></swc-custom-events></p>
</div>
// CustomEventExample.svelte
<svelte:options tag="swc-custom-events" />
<script>
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let name
$: (name) && dispatch('namechanged', { name })
</script>
Hello, {name}
<input bind:value={name}>
A workaround suggested in GitHub3 would be like below. There, you can have a wrapper to emit a DOM event also:
<svelte:options tag="swc-custom-events-fixed" />
<script>
import { createEventDispatcher } from 'svelte';
import { get_current_component } from 'svelte/internal';
const component = get_current_component();
const originalDispatch = createEventDispatcher();
const dispatch = (name, detail) => {
originalDispatch(name, detail);
component?.dispatchEvent(new CustomEvent(name, { detail }));
}
export let name
$: (name) && dispatch('namechanged', { name })
</script>
Hello, {name}
<input bind:value={name}>
You can use a component as a Svelte component or a custom element almost interchangeably. One of subtle difference would be how a set of styles defined in components applies.
A component with <svelte:options tag="tag-name" />
will have a shadow root.
On the other hand, child components in the above said component won't have a shadow root. The <style>
section will be extracted and merged into the parent's one. Thus,
// App.svelte
<svelte:options tag="swc-root" />
<script>
import StylesEncupsulated from './StylesEncupsulated.svelte'
let name = 'default name'
</script>
<h1>Styles</h1>
<p>As Svelte: <StylesEncupsulated {name} /></p>
<p>As WC: <swc-styles-encapsulated name={name}></swc-styles-encapsulated></p>
// StylesEncupsulated.svelte
<svelte:options tag="swc-styles-encapsulated" />
<script>
export let name
</script>
<span>Hello, {name}</span>
<style>
span { color: blue }
</style>
A simple workaround for this is to use inline style. Svelte compiler does not touch the inline styles, so it keeps existing and applies.
// StylesEncupsulated.svelte
<svelte:options tag="swc-styles-encapsulated" />
<script>
export let name
</script>
<span style="color: blue;">Hello, {name}</span>
But this is not cool since you must code the same styles repeatedly, as well as have scattered template code.
Svelte use the component classes directly to createElements.define
to register custom elements. If you enabled customElement
in compiler options, there's no way to control which component should be compiled to a custom element and which is not.
So you'll encounter Uncaught (in promise) TypeError: Illegal constructor at new SvelteElement
if you misses <svelte:options tag="swc-styles-encapsulated" />
in any component inside the project.4
28