Skip to content

Livewire multiple root elements: the version that never throws ​

Livewire multiple root elements usually announce themselves: the component fails to render, or the console prints "Multiple root elements detected". There is a quieter version where nothing complains. A Blade @if placed above the root tag can leave a component that renders, answers its first click, and then ignores every control a later update inserts. This post explains that case, how we hit it while building interactive sections for FilamentCraft, and the debug-mode check we added in v1.35.0 so nobody loses a day to it again.

Why Livewire needs one root element ​

A Livewire component is a server-rendered chunk of HTML that Livewire's JavaScript morphs in place after every request. To do that it has to know which DOM node is the component, so it attaches wire:id and the snapshot to the first element of the rendered view. The components docs list "Missing root element in your Blade template (Livewire requires exactly one root element)" as a cause of a component that shows blank or does not render.

The loud failures are the easy ones:

  • Two sibling tags at the top of the view. Livewire warns "Multiple root elements detected" in the browser console and the component misbehaves.
  • No tag at all. On the server, Livewire throws RootTagMissingFromViewException.

Both are well covered in forum threads. The one below is not, because it has no error to search for.

Blade conditionals leave comments in your HTML ​

Livewire's morph algorithm needs help with conditionals. When an @if flips from false to true, a naive DOM diff can match the wrong siblings and move nodes around. So Livewire rewrites your Blade before it compiles and wraps every conditional block in HTML comments. The morphing docs describe it: Livewire "automatically detects conditionals inside Blade templates and wraps them in HTML comment markers that Livewire's JavaScript can use as a guide when morphing." In the output they look like <!--[if BLOCK]><![endif]--> and <!--[if ENDBLOCK]><![endif]-->.

Those markers are invisible in the browser and harmless inside the root element. Above it, they are a problem. Here is the view shape we wrote, the kind of early exit that is perfectly normal in a plain Blade partial:

blade
@if ($items->isEmpty())
    @php return; @endphp
@endif
<div class="pricing-plans">…</div>

When $items is not empty, the @if prints nothing visible, but it still prints its markers. The rendered HTML now starts with a comment, not with the <div>.

What the silent failure looks like ​

This does not reliably throw. Livewire still finds an element to attach wire:id to, and the first render looks right. What goes wrong is the alignment: the node Livewire treats as the component root is not one its morph diff lines up against on the next update.

In our case the symptoms were:

  • The section rendered with the correct content and styling.
  • Buttons that were in the first server render worked.
  • After an update, controls that the update inserted rendered but did nothing when clicked.
  • No console error and no failed network request.

That combination points everywhere except the view's first line. You check the wire:click spelling, the method visibility and the Alpine scope long before you look at a comment you cannot see. It cost us a day.

Where FilamentCraft hit it ​

FilamentCraft has two kinds of section. A BladeSection renders plain Blade, so this rule does not exist for it. A LivewireSection is for interactive blocks such as a newsletter form or a pricing picker, and it extends Livewire\Component directly. The package mounts it with Livewire::mount() when the page renders, which means every Livewire rule applies to its view, including the root element one.

The FilamentCraft editor with the Hero section selected, its Content, Call to action, Media, Layout and Style groups in the left panel and the live page on the right
Section authors write a view and a settings list, and the editor builds this panel. A Livewire section's view carries one more rule than a Blade one.

A section author may be writing their first Livewire component inside someone else's page builder, and the failure gives them nothing to search for. So we did not stop at documenting the rule.

The fix: move the condition onto the root ​

The working idiom is to compute the condition in a @php block, which Livewire does not wrap in markers because it emits nothing, and put the result on the root element as an attribute:

blade
@php
    $hidden = $items->isEmpty() && ! $section->designMode;
@endphp

<div class="pricing-plans" @if ($hidden) hidden @endif>…</div>

The @if is now inside the root tag's attribute list, so its markers never sit above the root. $section->designMode is true inside the FilamentCraft editor, which keeps an empty section visible there so the author can still select it and fill it in.

The rule, stated as briefly as we could: nothing may print above the first tag. No @if, @foreach, @unless and no HTML comment. A @php block is fine.

Making it throw in debug mode ​

Documentation helps the people who read it. We also changed LivewireSection::renderToHtml() so both shapes fail loudly in development. Trimmed to the relevant part:

php
public static function renderToHtml(SectionData $data): string
{
    try {
        $html = Livewire::mount(static::class, ['section' => $data]);
    } catch (Throwable $e) {
        throw $e instanceof RootTagMissingFromViewException
            ? new LogicException(self::rootTagMessage(), previous: $e)
            : $e;
    }

    if (app()->hasDebugModeEnabled() && str_starts_with(ltrim($html), '<!--[if BLOCK]>')) {
        throw new LogicException(self::rootTagMessage());
    }

    return $html;
}

Two things happen here. Livewire's own RootTagMissingFromViewException is rethrown as a LogicException whose message names your section class and its view path, and says what to do instead. The generic exception does neither, which matters when the view is one of dozens. Then, with APP_DEBUG=true, the rendered HTML is checked for a leading morph marker. That is the silent case, turned into an exception on the first page load.

The check is gated on debug mode on purpose. A published page drops a section that throws rather than break the whole page, and since the same v1.35.0 release that failure is recorded per site and listed by php artisan filamentcraft:doctor. We did not want a production page to lose a section over a check whose only job is to help the author during development.

If you are not using FilamentCraft ​

The same check is a few lines in any app. A feature test that renders your component and asserts the HTML does not start with <!--[if BLOCK]> catches it in CI. Livewire also lets you turn the markers off with 'inject_morph_markers' => false in config/livewire.php, but the morphing docs present them as useful to the morph and note only that the regex detection "can sometimes fail". Moving the condition onto the root is the smaller change.

The custom sections guide has the full LivewireSection walkthrough, including how the $section data survives each wire:click, and the testing reference covers SectionDataFactory for building section fixtures in Pest, including interactive sections. To see interactive sections running inside the editor, open the live demo.

Last updated: