Search code examples
laravellaravel-livewire

Laravel Livewire wire:click creates infinite loop


I have a Livewire component that's a product filter. The queries all work fine, but sometimes it creates an infinite loop of requests.

You can see that happening in the GIF below which is a capture of the Laravel Debugbar. I'm clicking some filters and then suddenly it goes into this request loop.

enter image description here

I specifically use wire:loading.attr="disabled" on the filters in the view so someone can not select a filter while a request is still processing.

My code and some background:

Livewire Component

use App\Models\Product;
use App\Models\Brand;
use App\Models\Color;

class SearchProducts extends Component
{
    public ?array $brand = [];
    public ?array $color = [];

    protected $queryString = ['brand', 'color'];

    public function render()
    {
        $products = Product::query();

        $products = $products->with('brand');
        $products = $products->with('colors');

        $products = $this->filterBrands($products);
        $products = $this->filterColors($products);

        $products = $products->paginate(24);

        return view('livewire.search-products', [
            'all_brands' => Brand::where('status', 'active')->get(),
            'all_colors' => Color::where('status', 'active')->get(),
        ])->extends('app');
    }

    public function filterBrands($query)
    {
        $queryFilterBrand = array_filter($this->brand);
        
        return empty($queryFilterBrand) ? $query : $query->whereIn('brand_id', $queryFilterBrand);
    }

    public function filterColors($query)
    {
        $queryFilterColor = array_filter($this->color);

        return empty($queryFilterColor) ? $query : $query->whereHas('colors', function ($q) use ($queryFilterColor) {
            $q->whereIn('color_id', $queryFilterColor);
        });
    }

}

The reason that I use array_filter is because if I unselect a color value and use a character in the key (wire:model="brand.b{{ $brand->id }}"), instead of removing that from the array Livewire will set that key value to false. So then this false value will be put into the query which will give inaccurate results.

Livewire views and the issue

This works fine:

@foreach($all_brands as $brand)
    <input type="checkbox" value="{{ $brand->id }}" id="brand.{{ $brand->id }}" wire:model="brand.{{ $brand->id }}" wire:loading.attr="disabled">
    <label class="search-label search-wide-label mb-2" for="brand.{{ $brand->id }}">{{ $brand->title }} <i class="fal fa-times float-right selected-icon"></i></label>
@endforeach

But this creates an infinite loop when I select 2 or more colors after each other, or if I select 1 color and then deselect it. So it seems that issue occurs after the 2nd interaction:

@foreach($all_colors as $color)
    <input type="checkbox" value="{{ $color->id }}" id="color.{{ $color->id }}" wire:model="color.{{ $color->id }}" wire:loading.attr="disabled">
    <label class="search-label search-wide-label mb-2" for="color.{{ $color->id }}">{{ $color->title }} <i class="fal fa-times float-right selected-icon"></i></label>
@endforeach

This is weird because this blade snippet is exactly the same as for $brands as shown above:

The only thing that different is that the colors relationship is a hasMany vs a belongsTo for brand.

I'm now thinking that this is where the problem is...

The things I've tried and didn't work

  • Remove the @foreach loop for $all_colors and just put the filters in plain HTML (to check if the issue is related to the loop)
  • Adding wire:key="brand.{{ $brand->id }}" to the input element
  • Adding wire:key="brand.{{ $brand->id }}" to a div around the input element
  • Using wire:model="brand.{{ $brand->id }}" or wire:model="brand.{{ $loop->id }}" as was suggested in the comments (and what I thought solved the problem)
  • Using wire:model="brand.b{{ $brand->id }}" so there's a unique key name
  • Removing the array_filter approach (seems unlikely that this is the problem but just to test)
  • Using buttons instead of checkboxes
  • Using defer, lazy and/or debounce
  • Paying an expert to try and fix it...

Console error

Last piece, I get this error in my console only when the infinite loop happens so it's very likely either a cause or effect.

TypeError: null is not an object (evaluating 'directive.value.split')

Unhandled Promise Rejection: TypeError: null is not an object (evaluating 'directive.value.split')

Both in LoadingStates.js which I think is a Livewire Javascript file.

The error there seems to be happening here:

function startLoading(els) {
    els.forEach(({ el, directive }) => {
        if (directive.modifiers.includes('class')) {
            let classes = directive.value.split(' ').filter(Boolean)

Solution

  • Answered on GitHub issue, copied here for others to be able to find.


    The problem is a morphdom issue.

    The code that is triggering the errors and the loop is the wire:loading on your heading row and products row.

    The reason is, when you select two or more colours, there are no results shown. What happens then is you're swapping from showing heading/products/total to showing an empty state.

    But morphdom doesn't know by default that it should delete the old divs and add the new one. Instead it is trying to "morph" the old first div into the new one. That means that the wire:loading listeners are still registered when they shouldn't be. Hence why the error and the loop.

    It's a simple fix though. You need to add wire keys to the divs defining what they are, so morphdom knows that they have actually changed completely, and to delete the old ones and add new ones.

    Have a look at this screenshot below of the diff for what I did to get it working. I added a wire key for all the top level divs inside this file.

    It's recommended whenever using conditionals like that to add wire:keys to any elements that are first level inside the conditional, so morphdom knows there has been a change. It's the same problem VueJS has, where keys are required inside loops.

    Screenshot of diff