Phx-hook not working on an element mounted by a LiveComponent

I defined two hooks:

const liveSocket = new LiveSocket(
  '/live', Socket, {
    params: { _csrf_token: csrfToken },
    hooks: {
      Hook1: {
        mounted () {
          console.log('Hook1 mounted')
        }
      },
      Hook2: {
        mounted () {
          console.log('Hook2 mounted')
        }
      }
    }
  })

and a LiveComponent with a “panel” for each hook:

defmodule PhxPlayWeb.TestLiveComponent do
  use PhxPlayWeb, :live_component

  @impl true
  def render(%{panel: 1} = assigns) do
    ~H"""
    <div id="panel1" phx-hook="Hook1" class="mt-2 p-2 border-2">
      <.panel panel="1" target={@myself} />
    </div>
    """
  end

  def render(%{panel: 2} = assigns) do
    ~H"""
    <div id="panel2" phx-hook="Hook2" class="mt-2 p-2 border-2">
      <.panel panel="2" target={@myself} />
    </div>
    """
  end

  def panel(assigns) do
    ~H"""
    <div><%= "Panel #{@panel}" %></div>
    <button class="mt-2 p-1 border-2" phx-click="panel1" phx-target={@target}>Panel 1</button>
    <button class="mt-2 p-1 border-2" phx-click="panel2" phx-target={@target}>Panel 2</button>
    """
  end

  @impl true
  def handle_event("panel1", _, socket) do
    {:noreply, assign(socket, panel: 1)}
  end

  def handle_event("panel2", _, socket) do
    {:noreply, assign(socket, panel: 2)}
  end
end

Hook1.mounted() is called on the initial render, but Hook2.mounted() is not called when the Panel 2 button is clicked. Identical code (without the phx-target stuff) works fine in a LiveView:

defmodule PhxPlayWeb.HomeLive do
  use Phoenix.LiveView, layout: {PhxPlayWeb.Layouts, :app}

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, panel: 1)}
  end

  # @impl true
  # def render(assigns) do
  #   ~H"""
  #   <.live_component id="test-lc" panel={@panel} module={PhxPlayWeb.TestLiveComponent} />
  #   """
  # end

  @impl true
  def render(%{panel: 1} = assigns) do
    ~H"""
    <div id="panel1" phx-hook="Hook1" class="mt-2 p-2 border-2">
      <.panel panel="1" />
    </div>
    """
  end

  def render(%{panel: 2} = assigns) do
    ~H"""
    <div id="panel2" phx-hook="Hook2" class="mt-2 p-2 border-2">
      <.panel panel="2" />
    </div>
    """
  end

  def panel(assigns) do
    ~H"""
    <div><%= "Panel #{@panel}" %></div>
    <button class="mt-2 p-1 border-2" phx-click="panel1">Panel 1</button>
    <button class="mt-2 p-1 border-2" phx-click="panel2">Panel 2</button>
    """
  end

  @impl true
  def handle_event("panel1", _, socket) do
    {:noreply, assign(socket, panel: 1)}
  end

  def handle_event("panel2", _, socket) do
    {:noreply, assign(socket, panel: 2)}
  end
end

Shouldn’t Hook2.mounted() be called when the elemented is mounted by the component?

Your live component only gets mounted once as panel 1 which renders 2 buttons to switch between the panels. So on mount panel 1 renders then you click the button to render panel 2 this doesn’t get mounted again it just updates. You’ll need to use the updated lifecycle callback in your hook.

Then why does this work in a LiveView? My understanding is that mounted() is called when the element is added, not when the LiveComponent is mounted:

ah here you go this explains it:

Note: When using hooks outside the context of a LiveView, mounted is the only callback invoked, and only those elements on the page at DOM ready will be tracked. For dynamic tracking of the DOM as elements are added, removed, and updated, a LiveView should be used.

Right, I did see that… but this a LiveComponent inside a LiveView, so doesn’t that mean it’s in a “context of a LiveView”? In my actual application, I have a function component with a hook that was designed to be reusable. Kinda sucks if it only works in a LiveView but not in a LiveComponent.

You‘re right. This is in the context of a LV. Can you create a bug report in the LV repo and include a single file example to reproduce? Thank you!

Oh sorry, didn’t get notified of your reply. Yes, will file a big report, thank you!

Have you tried using updated() by the way?

If you mount a page and have content that is initially not rendered due to a conditional, the hook will not apply when the content is rendered as the mount has already occured, but you can use updated() to load the hook when the component is rendered after the mount.

For example:

Hooks.commentSectionHook = {
  mounted() {
    commentSectionHook();
  },

  updated() {
    commentSectionHook();
  },
};

I have a comment section that allows users to make comments, the comments get patched into the DOM when created, so don’t trigger a mount. The commenSectionHook code requires the ID’s of all comments to do…stuff.

The newly created comment can either be added to the list using a JS mutationObserver which is effort, or by retriggering the hook using updated()

You can also use phx-dispatch={(“your_hook_name”)} in the live_components attribute set to trigger a mount IIRC.

If your code conditionally renders the different panels based on button clicks, updated() will likely work.

Pretty sure you can use any DOM lifecycle calls when it comes to hooks. beforeMount() destroyed() updated() etc but I am not 100% if all of them work.

Each “panel” element has a unique id, so the two render() functions do not update any element, they add elements to the DOM. This means mounted, not updated should be called:

  • mounted - the element has been added to the DOM and its server LiveView has finished mounting
  • beforeUpdate - the element is about to be updated in the DOM. Note: any call here must be synchronous as the operation cannot be deferred or cancelled.
  • updated - the element has been updated in the DOM by the server.

Hooks are bound to life-cycle of DOM elements, not components, so it shouldn’t matter when the component is mounted. Again, everything works fine when rendering is done directly by the LiveView instead of a LiveComponent.

I filed a bug report at phx-hook not working on an element mounted by a LiveComponent · Issue #3423 · phoenixframework/phoenix_live_view · GitHub. There is a link to a repo there.

Each “panel” element has a unique id, so the two render() functions do not update any element, they add elements to the DOM. This means mounted, not updated should be called:

What you think Should be called is less important than what Is called?

It makes no sense for mount to be called when you conditionally render components, because one of the main appeals of liveview is that you can patch content and not fully load it constantly. I don’t think this is a bug, its just not working the way you want for this particular scenario.

I know for you a fact if you were to split the panels into two different components, that updated() would be called, because that’s how I do things all the time. I have dozens of conditionally rendered components that have hooks applied that work with updated() when rendered.

I know it seems like its not working, but imagine you have a large aplication, and render that component. The mount will reset everything whenever you load the panel. If the application is bigger than just two panels, that’s undesirable behaviour.

I don’t use your method of writing multiple renders in a single file, so I may be wrong about updated() being called for your use case.

Its worth checking in your console rather than just interpreting the hexdocs though. I don’t understand how updated() is not being called if you are adding and removing content from the DOM using serverside methods.

But yeah, tldr, I’m pretty sure this is working as intended with regards to not calling mount, because conditional rendering shouldn’t trigger full page resets.

It looks like the reason the mounted() callback isn’t being fired is because the DOM element is being updated., not mounted. You can see just the div attribute being updated here in the DOM inspector:

CleanShot 2024-09-16 at 09.21.17

The id and phx-hook attributes are updated by the LiveComponent, but because the element is not being destroyed and created there is no 'mount` callback.

This makes DOM updates incredibly efficient, but it might not be immediately obvious that the component div is being updated rather than mounted. Even though there are two render functions in your component only the minimal data is sent to the client to update the DOM, not the whole HTML blob.

If you move the handle_event callbacks to the LiveView (and remove the phx-target attributes so the event is sent up to the LiveView) the whole LiveComponent is removed, and a new one mounted. Then the mounted() callback is fired:

OK, but what if we want to keep the handle_event callback inside the Live Component? We can and that also keeps the DOM updates small and efficient. But we’ll lose the mounted() callback.

However, we can move the updates inside the hook and then get the updated() callback:

  def render(assigns) do
    ~H"""
    <div id="Panels" phx-hook="Hook1" class="mt-2 p-2 border-2">
      <.panel panel={@panel} myself={@myself} />
    </div>
    """
  end

Hook1 mounted() is called once, and updated() is called if the panel changes.

@Phxie @addstar @msmithstubbs If you are interested in following this topic, see Jose’s comment at phx-hook not working on an element mounted by a LiveComponent · Issue #3423 · phoenixframework/phoenix_live_view · GitHub