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?
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
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.
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.
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
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()
}
}
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
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.