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.
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
@foreach
loop for $all_colors
and just put the filters in plain HTML (to check if the issue is related to the loop)wire:key="brand.{{ $brand->id }}"
to the input
elementwire:key="brand.{{ $brand->id }}"
to a div
around the input
elementwire:model="brand.{{ $brand->id }}"
or wire:model="brand.{{ $loop->id }}"
as was suggested in the comments (and what I thought solved the problem)wire:model="brand.b{{ $brand->id }}"
so there's a unique key namearray_filter
approach (seems unlikely that this is the problem but just to test)defer
, lazy
and/or debounce
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)
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.