JS.set_attribute/JS.remove_attribute conflict with HTML attributes controlled via assigns

I had a use case where I wanted to disable a button when the LiveView has lost its connection and restored its enabled/disabled state when the connection recovers. I was able to achieve this using the socket connection lifecycle bindings in conjunction with JS.set_attribute/1 and JS.remove_attribute/1.

However, I also wanted to be able to control the buttons enabled/disabled state via a disabled attribute. It seems that, when using the aforementioned JS functions in the lifecycle events bindings, this no longer functions correctly; that is, the disabled HTML attribute is not added/removed, even though the value of the corresponding function component attribute is changing.

Does anyone know if this is the expected behaviour, or if I’m just missing something?

I have prepared a sample application to demonstrate what I’m describing:

Application.put_env(:sample, SamplePhoenix.Endpoint,
  http: [ip: {127, 0, 0, 1}, port: 5001],
  server: true,
  live_view: [signing_salt: "aaaaaaaa"],
  secret_key_base: String.duplicate("a", 64)
)

Mix.install([
  {:jason, "1.4.4"},
  {:plug_cowboy, "2.7.2"},
  {:phoenix, "1.7.19"},
  {:phoenix_live_view, "1.0.4"},
])

defmodule SamplePhoenix.ErrorView do
  def render(template, _), do: Phoenix.Controller.status_message_from_template(template)
end

defmodule SamplePhoenix.CoreComponents do
  use Phoenix.Component

  alias Phoenix.LiveView.JS

  @doc """
  Renders a button.

  ## Examples

      <.button>Send!</.button>
      <.button phx-click="go" class="ml-2">Send!</.button>
  """
  attr :type, :string, default: nil
  attr :class, :string, default: nil
  attr :disabled, :boolean, default: nil
  attr :rest, :global, include: ~w(form name value)

  slot :inner_block, required: true

  def button(assigns) do
    ~H"""
    <button
      type={@type}
      class={[
        "phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3",
        "text-sm font-semibold leading-6 text-white active:text-white/80",
        @class
      ]}
      disabled={IO.inspect(@disabled, label: "=====button===== disabled")}
      {@rest}
    >
      {render_slot(@inner_block)}
    </button>
    """
  end

  @doc """
  Renders a button that is disabled when disconnected and has its
  enabled/disabled state restored when reconnected.

  ## Examples

      <.button>Send!</.button>
      <.button phx-click="go" class="ml-2">Send!</.button>
  """
  attr :type, :string, default: nil
  attr :class, :string, default: nil
  attr :disabled, :boolean, default: nil
  attr :rest, :global, include: ~w(form name value)

  slot :inner_block, required: true

  def button2(assigns) do
    ~H"""
    <button
      type={@type}
      class={[
        "phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3",
        "text-sm font-semibold leading-6 text-white active:text-white/80",
        @class
      ]}
      disabled={IO.inspect(@disabled, label: "=====button2===== disabled")}
      phx-connected={
        case @disabled do
          true -> JS.set_attribute({"disabled", "true"})
          _ -> JS.remove_attribute("disabled")
        end
      }
      phx-disconnected={JS.set_attribute({"disabled", "true"})}
      {@rest}
    >
      {render_slot(@inner_block)}
    </button>
    """
  end
end

defmodule SamplePhoenix.SampleLive do
  use Phoenix.LiveView, layout: {__MODULE__, :live}

  import SamplePhoenix.CoreComponents

  def mount(_params, _session, socket) do
    {:ok, assign(socket, :disabled, false)}
  end

  def render("live.html", assigns) do
    ~H"""
    <script src="https://cdn.jsdelivr.net/npm/phoenix@1.7.19/priv/static/phoenix.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/phoenix_live_view@1.0.4/priv/static/phoenix_live_view.min.js"></script>
    <script>
      const liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket);
      liveSocket.connect();
    </script>
    <style>
      * { font-size: 1.1em; }
    </style>
    <div>
      <%= @inner_content %>
    </div>
    """
  end

  def render(assigns) do
    ~H"""
    <div>
      <.button type="button" disabled={@disabled}>No JS</.button>
      <.button2 type="button" disabled={@disabled}>JS.(set|remove)_attribute</.button2>
    </div>
    <div>
      <button phx-click="enable">Enable</button>
      <button phx-click="disable">Disable</button>
    </div>
    """
  end

  def handle_event("enable", _params, socket) do
    {:noreply, assign(socket, :disabled, false)}
  end

  def handle_event("disable", _params, socket) do
    {:noreply, assign(socket, :disabled, true)}
  end
end

defmodule Router do
  use Phoenix.Router
  import Phoenix.LiveView.Router

  pipeline :browser do
    plug :accepts, ["html"]
  end

  scope "/", SamplePhoenix do
    pipe_through :browser

    live_session :default do
      live "/", SampleLive, :index
    end
  end
end

defmodule SamplePhoenix.Endpoint do
  use Phoenix.Endpoint, otp_app: :sample
  socket "/live", Phoenix.LiveView.Socket
  plug Router
end

{:ok, _} = Supervisor.start_link([SamplePhoenix.Endpoint], strategy: :one_for_one)
Process.sleep(:infinity)

As can be seen in this application,

  • The first button (i.e., labelled “No JS”) is enabled/disabled when the corresponding button is clicked
  • The second button (i.e., labelled “JS.(set|remove)_attribute”) remains enabled regardless of whether the enabled/disabled buttons are clicked
    • However, if the connection is lost (e.g., by stopping the application), this button will be automatically disabled; when the connection is restored (e.g., by restarting the application) its previous enabled/disabled state will also be restored

In the meantime, I’ve worked around this using a client hook, but I’d be interested to know if anyone else has a more satisfying solution!

JS commands are sticky, so subsequent patches don’t override them. So what you’re seeing is expected. I think there’s currently no good way to undo those sticky operations, so a hook is probably the best solution for now. I’ll think about this a little bit more. Maybe we should have a JS.clear().

I had done some digging and noticed in the JS source that setting/removing attributes was being done using sticky attributes, and I suspected that may be causing the behaviour I was observing, so thank you for confirming @steffend! The hook is very simple and gets the job done just as well, but I wanted to make sure I hadn’t overlooked or misunderstood something.