Skip to content

Livewire, Web Components, and the Battle with Attributes

tldr; A bit of initial tweaking is necessary to harmonize Livewire with Web Components, but once set up, they cooperate smoothly.

Laravel and Livewire are respected and well-known for their great DX. Web Components on the other hand are best known for wide compatibility across different JavaScript frameworks AND even with plain HTML. On paper, they seem like a dream team, right?

Well – a while back, I experimented with Shoelace Web Components in combination with Livewire and immediately ran into issues with <sl-button> elements losing their styles after interaction. I didn't have a pressing use case, so I paused the project. Now, with Livewire 3 being available and a new idea for my website, I wanted to dive back in.

Livewire eats attributes which don't come from the server...

Problem

I installed a fresh Livewire project, added Shoelace via CDN and again replaced <button> elements with their <sl-button> counterparts.

After interaction the same style loss happened – for every component on the page.

Screenshot of Website with a bunch of form related Web Components.
Before interaction
Screenshot of Website with a bunch of form related Web Components where all their styles are lost.
After interaction

Here’s a breakdown of the issue:

  1. Initial Delivery: The server sends the component with its initial attributes:
<!-- counter.blade.php -->
<sl-button wire:click="decrement">
    Decrement
</sl-button>
  1. Hydration Phase: Upon hydration, sl-button adds its own attributes, which aren't known by the server:
<!-- DOM -->
<sl-button variant="default" size="medium" wire:click="decrement">
    Decrement
</sl-button>
  1. Morphing Phase: Alpine.js syncs with the server, finds no matching attributes, and therefore strips them, leading to:
<!-- counter.blade.php & DOM -->
<sl-button wire:click="decrement">
    Decrement
</sl-button>

Solution

To fix this, I tweaked Livewire's morphing system to preserve any DOM attributes of custom elements utilizing its morph.updating hook:

// app.blade.php (after Livewire)

Livewire.hook('morph.updating', ({
    el,
    component,
    toEl
}) => {
    // Check if element is a custom element;
    if (!el.tagName.includes('-')) {
        return;
    }

    // Store the original attributes.
    let oldAttributes = Array.from(el.attributes)
        .reduce((attrs, attr) => {
            attrs[attr.name] = attr.value;
            return attrs;
        }, {});

    // Restore all attributes that might have been removed by Livewire.
    let newAttributes = Array.from(toEl.attributes).map(attr => attr.name);
    Object.entries(oldAttributes).forEach(([name, value]) => {
        if (!newAttributes.includes(name)) {
            toEl.setAttribute(name, value);
        }
    });
});

Problems solved? Not quite.

...and sometimes it's right about that.

Problem

The situation got trickier when I tried to programmatically disable the button:

<sl-button
  {{ $count < 1 ? 'disabled' : '' }}
  wire:click="decrement"
>
  Decrement
</sl-button>

Let's recall my earlier modification:

I tweaked Livewire's morphing system to preserve any DOM attributes of custom elements [...].

Now, a disabled attribute, once set, couldn't be removed anymore. 🤦🏻‍♂️

Solution

I was at the point where I was forced to create workarounds:

  1. I set the falsy expression to the attribute prefixed with !.
<sl-button
   {{ $count < 1 ? 'disabled' : '' }}
   {{ $count < 1 ? 'disabled' : '!disabled' }}
    wire:click="decrement"
>
    Decrement
</sl-button>
  1. My hook got smarter, interpreting a ! prefix as a cue to drop the attribute in the DOM:
// app.blade.php (after Livewire)

Livewire.hook('morph.updating', ({
    el,
    component,
    toEl
}) => {  
    // Store the original attributes.
    let oldAttributes = Array.from(el.attributes)
        .reduce((attrs, attr) => {
            attrs[attr.name] = attr.value;
            return attrs;
        }, {});

    // Restore all attributes that might have been removed by Livewire.
    let currentAttributes = Array.from(toEl.attributes).map(attr => attr.name);
    Object.entries(originalAttributes).forEach(([name, value]) => {
       if (!newAttributes.includes(name)) {
       if (!name.startsWith('!') && !currentAttributes.includes(name)) {
            toEl.setAttribute(name, value);
        }
    });

   // Remove attributes starting with '!' from the `toEl`.
   Array.from(toEl.attributes).forEach(attr => {
       if (attr.name.startsWith('!')) {
           toEl.removeAttribute(attr.name.substring(
               1)); // Remove the corresponding actual attribute.
           toEl.removeAttribute(attr
           .name); // Remove the attribute with the '!' prefix if it exists from initial render.
       }
   });
});

This isn't standard practice, but it was effective for continuing.

Interoperability solved – but what about DX?

Problem

As initially said, Laravel and Livewire shine in their great DX. Yet, this improvisational "fake manual boolean" began to feel like a step backward. The situation worsened when I brought Livewire's wire:model into play with other form elements.

Plainly spoken: Form elements and Web Components (especially with ShadowDOM) are challenging. I was even surprised that sl-input and sl-textarea just worked out of the box with wire:model. Still, sl-radio-group, sl-checkbox and sl-select required a more hands-on approach involving event listeners and manual value synchronization:

<!-- Works out of the box! -->
<sl-input wire:model.live='input'></sl-input>

<sl-textarea wire:model.live='textarea'></sl-textarea>

<!-- Needs manual tweaking -->
<sl-checkbox
     {{ $checkbox ? 'checked' : '' }}
     x-on:sl-change="$wire.set('checkbox', $el.checked);"
></sl-checkbox>

<sl-select
    value="{{ $selected }}"
    x-on:sl-change="$wire.set('selected', $el.value);"
> ...</sl-select>

<sl-radio-group
    value="{{ $radio }}"
    x-on:sl-change="$wire.set('radio', $el.value);"
>...</sl-radio-group>

Solution

That's why I decided to write some practical Blade directives for the "hacky boolean toggle" and manual model bindings:

// app/Providers/AppServiceProvider.php

public function boot(): void
{
    // Sets an attribute if the value is defined and removes the attribute if undefined.
    Blade::directive('wcSetAttribute', function ($arguments) {
        list($attribute, $condition) = explode(',', $arguments);
        $attribute = trim(str_replace(['"', "'"], '', $attribute));
        $condition = trim($condition);
        return "<?php echo {$condition} ? '{$attribute}' : '!{$attribute}' ?>";
    });
  
    // Creates a model binding for sl-checkbox
    Blade::directive('slCheckboxModel', function ($arguments) {
        list($expression, $value) = explode(',', str_replace([' ', '"', "'"], '', $arguments));
        return "<?php echo {$value} ? 'checked' : '' ?> x-on:sl-change=\"\$wire.set('{$expression}', \$el.checked);\"";
    });
  
    // Creates a model binding for sl-select including multiple select
    Blade::directive('slSelectModel', function ($arguments) {
        list($expression, $value) = explode(',', str_replace([' ', '"', "'"], '', $arguments));
        return "value=\"<?php echo is_array({$value}) ? implode(' ', {$value}) : {$value}; ?>\" x-on:sl-change=\"\$wire.set('{$expression}', \$el.value);\"";
    });

    // Creates a model binding for sl-radio-group
    Blade::directive('slRadioGroupModel', function ($arguments) {
        list($expression, $value) = explode(',', str_replace([' ', '"', "'"], '', $arguments));
        return "value=\"<?php echo {$value}; ?>\" x-on:sl-change=\"\$wire.set('{$expression}', \$el.value);\"";
    });
}

This refactoring allowed a much neater usage in our Livewire component:

<sl-button
    @wcSetAttribute('disabled', $count < 1)
	wire:click="decrement"
>...</sl-button>

<sl-checkbox
    @slCheckboxModel('checkbox', $checkbox)
>...</sl-checkbox>

<sl-select
    @slSelectModel('selected', $selected)
 >...</sl-select>

<sl-radio-group
    @slRadioGroupModel('radio', $radio)
>...</sl-radio-group>

There might be a way to make this even simpler, but for now, I'm happy with that. 💪🏻

Conclusion

I'm looking forward to see how it'll work when I add new features to my website. In the meantime, I'd love to hear what you think: Have you ever mixed Livewire with Web Components? Are you thinking about it? Did I miss something in my approach? Jump into the conversation on Mastodon and let me know!

Reactions on Mastodon Post:

4
2
4