Is there any way to update props inside a phx-ignore?

I have (roughly) this code:

<div id="select-wrapper" phx-update="ignore">
  <select phx-hook="SlimSelect" value={@value} />
</div>

I’ve done this because my ReactSelect hook will hide the select and add a sibling in its place. Ignore on the parent ensures neither of these changes get wiped out by other form updates. But a huge downside to this is if the @value is updated elsewhere, it won’t be committed to the select.

Is there any way to keep LiveView from wiping out my JS component, while also reacting to changes to assigns?

Thanks!

How about receive event from server by window.addEventListener or this.handleEvent? You can make function that updates assigns.value and use push_event/3 in that function every time you update value

Maybe this document would help you

Hmm, how about removing the wrapper and having the hook add a sibling with phx-update="ignore" as well as optionally notifying that sibling when @value changes.

As a general rule of thumb, I tend to avoid using phx-hook and other Phoenix LiveView bindings nested within phx-update="ignore" since it helps keep a cleaner boundary between Elixir and JS.

Hrm, sadly that doesn’t work. When there’s a change that causes a Phoenix re-render, I’m left with a detatched JS select that no longer works, along we a new unstyled HTML select.

Could you move phx-hook to the parent? I’d say phx-hook + phx-update=“ignore” is redundant, because the hook can choose to ignore if it wants to.

Is there an example somewhere of how the hook can ignore changes coming in?

I may be mistaken, but the hook has an updated callback which you can use to revert or ignore changes, correct?

Updated can ignore morphdom yes, I used this to integrate surface and bootstrap.

@olivermt do you have an example available somewhere?

I can move my hook up a level, but I still have the issue that LiveView won’t update props inside the ignore. I can try to also set these props on the hook’s component, and add code that will then just pass on these props to the ignored element. It all feels very messy- definitely fighting against the framework.

Here’s all the actual code:

  def input(%{type: "select"} = assigns) do
    ~H"""
    <div id={@id <> "-parent"} phx-feedback-for={@name} phx-update="ignore">
      <.label for={@id}><%= @label %></.label>
      <select
        id={@id}
        name={@name}
        multiple={@multiple}
        data-addable={@addable}
        phx-hook="SlimSelect"
        {@rest}
      >
        <option :if={@prompt} value="" class="dark:!text-white"><%= @prompt %></option>
        <%= IdoWeb.SlimSelectForm.options_for_select(@options, @value, "dark:!text-white") %>
      </select>
      <.error :for={msg <- @errors}><%= msg %></.error>
    </div>
    """
  end
Hooks.SlimSelect = {
  mounted() {
    const select = this.el;
    const isAddable = select.dataset.addable === "";
    // for now just identity function to accept any addition
    const addable = isAddable ? (value) => value : undefined;
    new SlimSelect({ select, events: { addable } });
  },
};

As per the docs

For integration with client-side libraries which require a broader access to full DOM management, the LiveSocket constructor accepts a dom option with an onBeforeElUpdated callback. The fromEl and toEl DOM nodes are passed to the function just before the DOM patch operations occurs in LiveView. This allows external libraries to (re)initialize DOM elements or copy attributes as necessary as LiveView performs its own patch operations. The update operation cannot be cancelled or deferred, and the return value is ignored.

I did a small proof of concept using select2 and it works fine.

app.js

let mountSelect = () => {
    $(document).ready(function () {
        $('#select2').select2();
    });
}

let Hooks = {}

Hooks.Select2 = {
    mounted() {
        mountSelect()
    }
}

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {
    dom: {
        onBeforeElUpdated(from, to) {
            if (from.id == "select2") {
                mountSelect()
            }
        }
    },
    hooks: Hooks,
    params: { _csrf_token: csrfToken }
})

The liveview

defmodule SelectExampleWeb.SelectLive do
  use SelectExampleWeb, :live_view

  def mount(_params, _session, socket) do
    options = [{"First", "1"}, {"Second", "2"}, {"Third", "3"}]
    {:ok, socket |> assign(:options, options) |> assign(:selected_value, "3")}
  end

  def render(assigns) do
    ~H"""
    <div>
      <.form for={}>
        <.input type="select" options={@options} name="select-field" value={@selected_value} phx-change="changed-value" />
      </.form>

      <select id="select2" phx-hook="Select2">
        <option :for={opt <- @options} value={elem(opt, 1)} selected={elem(opt, 1) == @selected_value}>
          <%= elem(opt, 0) %>
        </option>
      </select>
    </div>
    """
  end

  def handle_event("changed-value", %{"select-field" => value}, socket) do
    {:noreply, socket |> assign(:selected_value, value)}
  end
end

Why do you need both the Hooks and onBeforeElUpdated?

1 Like

For simple things you don’t, you can use the updated() callback in the hook to do the same, but in the case of a more complex library operating in the entire DOM i would still use it to have access to hook lifecycle callbacks, this was an unidirectional proof of concept, if you change the value outside of the select2 it updates inside it, but if you want to make the updates in the select2 be visible to the rest of the app you can rewrite the hook and make it bidirectional.

Hooks.Select2 = {
    mounted() {
        mountSelect()
        $('#select2').on('select2:select', (e) => {
            let value = e.params.data.id
            this.pushEvent("changed-value", { "select-field": value })
        });
    },
    updated() {
        mountSelect()
    }
}

Should work just fine.

1 Like

I tried this and got some strange results.

  1. call this from onBeforeElupdated and from hook: I get two styled selects. I think one from the updated, one from the hook
  2. only call this from onBeforeElUpdated: I have the unstyled original select, along with a styled one

In both instances, when there’s another change on my page, the styled component(s) display their creation animations.

app.js

const liveSocket = new LiveSocket("/live", Socket, {
  params: { _csrf_token: csrfToken },
  hooks: Hooks,
  dom: {
    onBeforeElUpdated(from, to) {
      if (from.nodeName == "SELECT") {
        mountSelect(from.id);
      }
    },
  },
});

Disclaimer: I haven’t tried the code in this thread yet.

When you update the dom element. On every update, liveview considers this an updated element on the client side:

It is expected that the updated hook is called anytime the DOM differs from what the server sends. If you have your own js that is setting attributes then you’ll necessarily have updated called when a patch occurs…

So it think this might be the behaviour you’re seeing

1 Like

Yeah, that looks like what’s happening. I’m not sure how to handle that. I don’t see a way to detect when I need to trigger the reinitialization. I tried adding a data attribute to my select when I initialize SlimSelect, so I’ll ignore it in future updates until that attribute is missing. But that just isn’t working- sometimes it’s present but the component still needs to be initialized, and sometimes it’s absent when it has been.

I also tried wrapping a select in a web component to move this mess out of LiveView, but that quickly became a nightmare for similar reasons.

I haven’t looked at this after I reported the issue
My current thoughts would be to keep some external state outside of the component and use that to decide what needs to happen.

Tried with slimselect, you can try handling the mountSelect inside the updated callback of the hook, it kinda works for the issue of the multiple styled selects, not for the animation, watching the DOM what is happening is that the library completely hide the original select and do his own thing in a sibling node which means that onBeforeElUpdated would be a mess to handle (if it is even possible) and that remounting without destroying it would make the DOM a mess very fast since each remount creates a bunch of nodes and there’s also a duplicate id issue in the library that throws liveview completely out of control.

This is a case where I would throw a phx-update=“ignore” on the parent node and use handleEvent and pushEvent from the javascript just to avoid these issues.

Thanks for trying it out- sounds like exactly the walls I was hitting too. Yeah my thought is either to do like you said as well as update the wrapping hook to drill the props down to the child select, or finally caving and pulling in a small JS lib (Alpine, Svelte, etc.) for a cleaner wrapping / pass-through.