Liveview re-rendering entire form instead of one input

hello - in the (very) simplified example below, liveview re-renders the entire form whenever (the datalist of) one input changes, thereby losing the as-yet-unsubmitted inputs. Is there a way to re-render only the input which actually changes (and hence preserving the as-yet-unsubmitted inputs)?

(The data sent over the socket does look correctly restricted to the datalist, so it looks like the unwanted re-rendering is happening on the js/browser side of things - observed both in firefox and chrome.)

  def mount(_params, _session, socket) do
    {:ok, assign(socket, q: "")}
  end

  def render(assigns) do
    ~H"""
      <form method="post">
        <input type="checkbox" name="c[]" value="cm"> check me
        <field>
          <input type="text" name="q" list="autosuggest" phx-change="update-q" 
              placeholder="type only after clicking the checkbox" autocomplete="off"/>
          <datalist id="autosuggest">
            <option value={"#{@q}-#{@q}"} />
            <option value={@q} />
          </datalist>
        </field>
      </form>
    """
  end

  def handle_event("update-q", %{"q" => q}, socket) do
    {:noreply, assign(socket, :q, String.upcase(q))}
  end

In this example, clicking on the checkbox to check it and then starting to type in the text input unchecks the checkbox, losing user input.

Thank you!

I think this may be of some use to you: Form bindings ā€” Phoenix LiveView v0.17.11

Specifically, I think you want to look at the use of phx-change and handle_event and how that is triggering the re-render of your form.

Thank you, Iā€™m not sure how you think that would help? It is clear what is triggering the re-render (ie the change of the q assign), and as I noted in the topic above, it is also clear what goes down the socket. The issue is about the scope of the re-render (the whole form, apparently on the browser side only), rather than a single input or datalist.

In LiveView all state is stored server-side on the socket as assigns. You need to track the current state of your inputs in your socket assigns. You can do this with individual assigns on individual inputs, for instance:

<input type="checkbox" name="c[]" value="cm" phx-change="update-c" checked={@c_checked}>

or you can use an Ecto.Changeset to manage the form state as a whole, which is what @muelthe was pointing out. :slight_smile:

Thank you, I know all of that - I must have been unclear in the topic above: the point is about cases where you want to update only one tiny subpart of a form, such as the datalist (autocomplete) item while the user types in the text input field. At that point, the other inputs have not been submitted yet, so cannot be stored in assigns. The phx-change on the text input transmits only the data of that input, as it should, and its datalist gets updated correspondingly.

At first sight, liveview does the right thing here of sending a response with only the data of that particular input down the socket from the server to the browser, leaving the other fields out of the response. But for a reason I so far donā€™t understand, the whole form gets recreated on the js/browser side nevertheless, thus wiping out the not-yet-submitted input values.

(Another way to point to the issue: if I factor out the checkbox into its own function component, that component does not get re-rendered when the q assign changes ā€“ which is great. This can be seen by putting an IO.inspect or logging statement inside the factored out function component ā€“ it doesnā€™t get called on update of the text input. Despite that, the js/browser side does update the checkbox component when the text input changes, and it is so far unclear to me why.)

Liveview doesnā€™t know the state of your checkbox because you never assign it. Itā€™s going to be reset on each render, i.e. each change to q.

You need to put a change event on the entire form or every individual input or a combination of the two.

Your answer was already included in the question!! Ie: ā€œinputs have not been submitted yet, so cannot be stored in assigns.ā€ On the other hand, you seem to have missed the part where the checkbox " does not get re-rendered when the q assign changes" and ā€œThe data sent over the socket does look correctly restricted to the datalist [ie without the checkbox data], so it looks like the unwanted re-rendering is happening on the js/browser side of thingsā€.

Once again, the liveview server-side and on-the-wire behavior is apparently exactly right and as expected, the question is why the js/browser side re-renders the entire component while the server-side does not. Very nice diff-ing is done on the server, why is not just the diff applied on the browser? And is there a way of having just that diff applied on the browser side?

Seems like a bug to be honest.

As a workaround, this will fix it, but I think it should not be needed:

<input type="checkbox" name="c[]" value="cm" phx-update="ignore" id="my-checkbox">

Edit
Found this comment from @chrismccord Checkboxes check state can't be overwritten by server reliably Ā· Issue #615 Ā· phoenixframework/phoenix_live_view Ā· GitHub

I interpret it as - checkbox state should also be maintained by the LiveView.

Thanks for phx-update="ignore" @egze ! I didnā€™t know about that, and I agree that this is a not-ideal workaroundā€¦ (Note that the issue is independent of checkboxes: the bug remains the same if the first input is changed to a text input.)

Going through the liveview browser-side js code, the answer is now clear: the loss of unsubmitted input is a consequence of the algorithm in mergeDiff() (in rendered.js): it operates on a cached copy of the previous data of the entire form, applies the diff to that cache (in view.js update(diff, events) ā†’ renderContainer(diff, events)), and then overwrites the entire form html with a rendered version of the updated cache (in DOMPatch & morphdom(targetContainer, diffHTML, {...}). Which of course erases all browser state that has not yet been uploaded ā€“ creating the bug at issue in this thread.

@chrismccord - how feasible would it be to keep track of which leaf DOM nodes were patched (presumably in or around DOMPatch) and only updates those leaf DOM nodes instead of the entire component? That would preserve as-yet-not-uploaded browser state.

Heya @starkeepers sorry the link wasnā€™t of any use. Iā€™m still not 100% certain I understand the problem (itā€™s been bugging me all day! :smiley: )

Am I to understand, youā€™re looking for the following to happen in your example when you interact with the form?

  • Check the box
  • Input values in the text field (the checkbox should remain checked)
  • Do something to the values of the text field and make them available via assigns for further opertations

Iā€™m quite new to working with liveview and trying to understand the crux of your issue helps me to expand my own knowledge.

Thatā€™s some great detective work. I think your best option is to either make the checkbox value part of your state, or ignore with phx-ignore.

Anything else goes against how LiveView works.

Itā€™s not a bug. You need to either track the form changes with phx-change="..." and assign the checkbox value, or you need to tell us to ignore the inputs with phx-update="ignore">

3 Likes

If Iā€™ve understood the issue (if! :sweat_smile:), I got the following to work without using a changeset. Would love to know if this is aiming at all in the direction of what youā€™re looking to achieve.

def mount(_params, _session, socket) do
    {:ok, assign(socket, q: "")}
  end

  def render(assigns) do
    ~H"""
    <.form let={f} for={:source} phx-submit="save">
      <%= checkbox f, "cb[]", value: "cm" %>
      <%= text_input f, "q",  phx_change: "update-q" %>
    </.form>
    """
  end

  def handle_event("update-q", %{"source" => %{"q" => q}}, socket) do
    # do stuff to "q"
    {:noreply, assign(socket, :q, q)}
  end

This is assuming you donā€™t want to use changesets and only want to send "q" back to your handle_event.

Again, I appreciate Iā€™ve not completely understood the problem - but the experience has been good.

hey @muelthe ā€“ maybe it helps if I give a more realistic use-case rather than a trimmed down minimal example as above. Imagine you have a form to upload books. The user starts by typing the title of the book, then maybe the year it came out, the topic, etc. and after that the author(s) of the book. You already have 90% of the autors in your database, so you want to help the user by providing an autocomplete on the author text input. At every keystroke, ecto will search through the authors table, and offer matches to the user so they donā€™t have to type in the whole author.

The form might look something like this, assuming each field is its own function component for simplicity of the code below (but it works exactly the same for our purposes if we donā€™t use function components):

  def render(assigns) do
    ~H"""
      <.form phx-submit="save" etc ... >
        <.title title={@title} etc ></.title>
        <.year ... ></.year>
        ... more fields here ...
        <.author_autocomplete suggested_authors={@suggested_authors} etc></.author_autocomplete>
        ... yet more fields ...
        <%= submit "Continue" %>
      </.form>
    """
  end

  def author_autocomplete(assigns) do
    ...
    <input type="text" phx-change="search-author" list="author-datalist" name="author" ... >
    <datalist id="author-datalist">
        <%= for author <- @suggested_authors do %>
          <option value={author} />
        <% end %>
    </datalist>
    ...
  end

  def handle_event("search-author", %{"author" => query}, socket) do
    ...
    {:noreply, assign(socket, :suggested_authors, result_of_ecto_search)}
  end

As usual, the form sends data for all the field upon submit, triggering handle_event("save", params, socket). So far, it sounds innocuous (I hope), but consider what will actually happen:

The user types in the title, the year, and whatever other fields, then starts typing into the authors field. As soon as they type the first character (or two), the search-author handler is invoked, which populates the suggested_authors assign, which triggers a re-render. Server-side, liveview is smart so it only renders the author_autocomplete() component inside the form, ignoring the others, and only transmit the suggested_authors down the socket, leaving all other fields out.

But when the data reaches the browser, things suddenly go very wrong for the end user: the liveview javascript uses the suggested_authors data to recalculate the html for the entire component, based on a cached version of the data used to render the earlier version of the component + the diff received from the server. The recalculated html now replaces the user-visible version of the component, thus ā€¦ wiping out all the data that the poor user typed in (but offering the autocomplete suggestion).

There are a few things one can do to avoid this, but I was asking above if liveview could natively avoid it without us working around it, by not replacing the entire component (in the DOM) and rather replacing only the minimal dom nodes that were actually changed (leafs of the dom). The answer is ā€œnoā€ :slight_smile:

Possible options to work around this include:

  • encapsulate the autocomplete field into its own live_component (I havenā€™t tried this yet, so for now Iā€™m only assuming that updating an embedded live component does not trigger a re-render of the parent component.)
  • add a phx-update="ignore" to each of the other fields, as @egze suggested
  • revert to ā€˜deadā€™ routes (ajax-style) to update the list of suggestions (the datalist in the example above), thus bypassing liveview re-rendering
  • add a phx-change to each field (actually, on the whole form, thanks @cmo for the correction), thus keeping all state on the server in the assigns as soon as the user fills in that field

And probably more :slight_smile:

At least thatā€™s my understanding for now, Iā€™m happy to be corrected. I hope that helped!

2 Likes

Typically you put a phx-change on the form and keep track of the form state on the server. Keeping track of state on the server is the whole liveview model.

If youā€™re changing things on the front-end, not tracking those changes and not using phx-change="ignore" on them, then expect to have them wiped out.

If you ignored all the other inputs, have you considered what is going to happen to them after you save the form?

Note that updates donā€™t have to be sent on every keystroke, you can denounce it.

https://hexdocs.pm/phoenix_live_view/bindings.html#rate-limiting-events-with-debounce-and-throttle

1 Like

Indeed, the last option should be phrased as putting the phx-change on the form, not each input - Iā€™ve updated it, thanks. I should have been doing that from the start, but was trying to avoid uploading everything all the time.

(I did try debounce, but so far it degraded the responsiveness of the autocomplete, maybe I havenā€™t found the right values yet.)

As for ā€œhave you considered what is going to happen to them after you save the form?ā€ ā€“ Iā€™m not sure I understand the question, but if I do, ā€œobviously yesā€, otherwise why would I have them on the form to begin with? (The app worked fine, until some haphazard circumstance uncovered a version of the issue above.)