DEV Community

loading...

Svelte for Web Components development: Pitfalls and workarounds

tnzk
Develops a Web Components library for video chat these days. Also, I'm running a coding bootcamp in Japan.
・7 min read

Svelte components can be compiled to custom elements, aka web components.

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.

Corresponding code for repro and trying out yourself the workarounds are on GitHub. The working example is available online via Vercel.

Attributes named in kebab-case won't be recognized

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}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Attributes with upper-case letters won't be recognized

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` -->
Enter fullscreen mode Exit fullscreen mode

Changing one of props via DOM API applies to the component, but bind mechanism doesn't work

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'
Enter fullscreen mode Exit fullscreen mode

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.

You can't pass an object other than a string through attributes

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}>
Enter fullscreen mode Exit fullscreen mode

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.

Emiting a custom event in Svelte doesn't emit a DOM event automatically

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}>
Enter fullscreen mode Exit fullscreen mode

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}>
Enter fullscreen mode Exit fullscreen mode

Styles defined in child components doesn't apply

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

But this is not cool since you must code the same styles repeatedly, as well as have scattered template code.

Uncaught (in promise) TypeError: Illegal constructor at new SvelteElement

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


  1. https://html.spec.whatwg.org/multipage/custom-elements.html#concept-custom-element-definition-observed-attributes 

  2. https://github.com/sveltejs/svelte/issues/875 

  3. https://github.com/sveltejs/svelte/issues/3852 

  4. https://www.notion.so/tnzk/Svelte-Web-Components-2021-7-fc7b724677bf4c68b6289e8d0ca241b6#c666e54ccfe54e98a4c72626bec2a502 

Discussion (0)