Live component inside slot of another live component works, but when using :for for the slot, gives error "A component must always be returned directly as part of a LiveView template"

Hello,

I have a tabs LiveComponent like this:

tabs.ex:

defmodule SqlrWeb.LiveComponents.Tabs do
  use SqlrWeb, :live_component

  @impl true
  def mount(socket) do
    {:ok, socket |> assign(:active_tab, nil)}
  end

  @impl true
  def handle_event("on_tab_select", params, socket) do
    {:noreply, assign(socket, :active_tab, params["tab"])}
  end
end

tabs.html.heex:

<div>
  <ul class="flex flex-wrap text-sm text-center text-gray-500 border-b border-gray-200 w-fit dark1:border-gray-700 dark1:text-gray-400">
    <li :for={tab <- @tab} class="mr-2">
      <a
        href="#"
        class={[
          "inline-block px-3 py-1 rounded-t-lg hover:text-gray-600 hover:bg-gray-50 dark1:hover:bg-gray-800 dark1:hover:text-gray-300",
          @active_tab == tab.id &&
            "font-medium bg-gray-100 active dark1:bg-gray-800 dark1:text-blue-500"
        ]}
        phx-click={@on_tab_select && @on_tab_select}
        phx-value-tab={@on_tab_select && tab.id}
        phx-target={@myself}
      >
        <%= tab.label %>
      </a>
    </li>
  </ul>
  <div :for={tab <- @tab} :if={@active_tab == tab.id}>
    <%= render_slot(tab) %>
  </div>
</div>

I also have another LiveComponent, a very basic one, just for testing:

detail.ex:

defmodule SqlrWeb.LiveComponents.Detail do
  use SqlrWeb, :live_component

  @impl true
  def mount(socket) do
    {:ok, socket |> assign(:state, nil)}
  end
end

detail.html.heex:

<div>
  ID: <%= @id %> State: <%= inspect(@state) %>
</div>

If I use the Detail LiveComponent inside the Tabs LiveComponent in a LiveView like this, it works as expected:

<div>
  <%!-- Other stuff here --%>
  <.live_component module={Tabs} id={"detail-tabs"} on_tab_select="on_tab_select">
    <%!-- Explicit tab slots --%>
    <:tab id="explicit-tab-1" label="One">
      <.live_component module={Detail} id="explicit-detail-1" state="Explicit Detail 1" />
    </:tab>
    <:tab id="explicit-tab-2" label="Two">
      <.live_component module={Detail} id="explicit-detail-2" state="Explicit Detail 2" />
    </:tab>
  </.live_component>
</div>

But if I want to enumerate the tabs dynamically based on an array assign of the LiveView:

  @impl true
  def mount(_params, _session, socket) do
    details = [
      %{id: "detail-1", state: "Detail 1 state"},
      %{id: "detail-2", state: "Detail 2 state"}
    ]
    {:ok, assign(socket, :details, details)}
  end

and in the LiveView’s .html.heex:

<div>
  <%!-- Other stuff here --%>
  <.live_component module={Tabs} id={"detail-tabs"} on_tab_select="on_tab_select">
    <%!-- Tab slots by comprehension --%>
    <:tab :for={detail <- @details} id={"tab-#{detail.id}"} label={"#{detail.id}"}>
      <.live_component module={Detail} id={"#{detail.id}"} state={"#{detail.state}"} />
    </:tab>
  </.live_component>
</div>

then I get the error

** (ArgumentError) cannot convert component SqlrWeb.LiveComponents.Detail with id "detail-1" to HTML.

A component must always be returned directly as part of a LiveView template.

Am I doing something that I shouldn’t? I have found the same error message in several other forum posts, but none gave me an ideea how to solve my issue.

Thanks.

3 Likes

Unfortunately you cannot combine :let and :for on a slot. You need to put the for outside of the slot

See “Dynamic function components with let”:

1 Like

Thank you for the answer. I remember now that I actually read Sophie’s article back then, when it was published, but I forgot about it (and I don’t think this restriction is mentioned in the docs).

I tried, it does not work either. This:

<div>
  <%!-- Other stuff here --%>
  <.live_component module={Tabs} id={"detail-tabs"} on_tab_select="on_tab_select">
    <%!-- Tab slots by comprehension --%>
    <%= for detail <- @details do %>
      <:tab id={"tab-#{detail.id}"} label={"#{detail.id}"}>
        <.live_component module={Detail} id={"#{detail.id}"} state={"#{detail.state}"} />
      </:tab>
    <% end %>
  </.live_component>
</div>

gives:

(Phoenix.LiveView.HTMLTokenizer.ParseError) invalid slot entry <:tab>. A slot entry must be a direct child of a component

I feel that the solution is to use a single <:tabs /> named slot (instead of multiple <:tab /> slots) with the array of tabs as an assign, but I did non found o working solution yet. I will keep trying.

1 Like

Please open up a bug report. I feel it should work but, if we cannot make it work, then we should document it.

2 Likes

Nevermind, I have fixed it in main/master.

7 Likes

Awesome!

I can confirm that it works when using the github Phoenix LiveView dep in mix.exs:

      ...
      {:phoenix_live_view,
       git: "https://github.com/phoenixframework/phoenix_live_view.git", override: true},
      ...

The “child” LiveComponent for the specific selected tab is displayed correctly. So thank you very much for the quick fix!

(LiveView v. 0.18.4 was just released, but Jose’s fix is only on master for now; it will be included in the next release).

As about the restriction in the Sophie’s article, it still holds true: You cannot combine :let={some_var} and :if with a condition on some_var in the same tag like this:

  <.unordered_list items={@books}>
    <:list_item :let={book} :if={book.available}> # <-- this won't work!
      <%= book.title %>
    </:list_item>
  </.unordered_list>

the error being:

** (CompileError) lib/myapp_web/live/myliveview_live/show.html.heex:36: undefined function book/0 (expected MyappWeb.MyliveviewLive.Show to define such a function or for it to be imported, but none are available)

So the scope of the book variable defined by :let is only inside the slot tag (between <:list_item > and </:list_item>) and it cannot be accessed on the slot tag’s attributes.

Instead, as mentioned in the articol, you have to push the conditional logic (like displaying only available books) inside the component:

  def unordered_list(assigns) do
    ~H"""
    <ul>
      <%= for item <- @items do %>
        <%= if item.available do %>
          <li>
            <%= render_slot(@list_item, item) %>
          </li>
        <% end %>
      <% end %>
    </ul>
    """
  end