Make `JS.t()` a public data structure, or json serializable

Currently the Phoenix.LiveView.JS struct is defined as an @opaque type, meaning it’s fields are internal api not to be relied upon.

I have the following use case I’d like to support to keep working in elixir land as much as possible:

  • I have a toast notifications implementation in JS
  • The liveview process sends events to show or dismiss toasts, and the client handles via window.addEventListener, so I’m able to fully control the toasts api with liveview
  • I need to add additional behavior to the toasts: the toasts can have an action, so when the user clicks them some JS will run
  • This can be achieved normally with the phx-click attribute set on the toast, and it works if the value is a simple event name string and the value is in phx-value, but it gets ugly to implement when the value should be the result of many chains of JS.* functions

This is trivial to solve if I do this:

%{
  actions: %{
    "Foo" => Jason.encode!(JS.dispatch("foo", detail: %{some: "arg"}).opts)
  }
}

However, the opts field is private, so I can’t just rely on this approach always working.

Is there any chance of this field being public in the near future, or for the JS struct to be json serializable via some phoenix public api?

3 Likes

I’ve build custom executions of js commands just be sending the string and using LiveSocket.execJS: JavaScript interoperability — Phoenix LiveView v0.18.13

That works of course, the problem is that liveview doesn’t control the rendering of the toast element(I’m using toasts here as an example, but the same applies to other “js sprinkles” that render content with js in a container with phx-update="ignore"). For that approach to work I’d need again to serialize the js command, which apparently isn’t possible with the current public apis.

I’m not sure I understand the issue then. You can call execJS whenever you need.

execJS is JS code; the point is to not have to drop to JS code to run a JS command. It also relies in me having access to the serialized JS command, which I don’t.

Normally, with templates, you can do this:

<button phx-click={JS.add_class("active")}>Foo</button>

However, in my case the rendering of the “button” happens in JS code, outside of Liveview’s reach. This means that I need to write a JS hook or some other code to run that command or something equivalent. In other words, I need to write specialized JS code for each interaction I want to add to that dynamically created element and I want to avoid that.

To avoid that, I can tell the js rendering code to render the attributes I pass as the payload for the “show toast” event, meaning I can leverage phx-click for this and not have to worry about anything else in the JS code, letting me write elixir code like:

socket
|> show_toast(%{message: "Something happened", actions: %{"Undo" => JS.some_command(...)}})

Which would render the following in JS land:

<div class="toast">
  <span>Something happened</span>
  <button phx-click="serialized js command here">Undo</button>
</div>

The problem is that there’s no public api to serialize the JS struct here, so it will throw an error. I can still do that with Jason.encode!(JS.some_command(...).opts) but because the :opts is private api I have no guarantee this won’t break in upcoming liveview releases, nor that it will keep being JSON.

The whole point of the exercise is to not have to resort to hooks or any other JS code whatsoever. phx-click and similar are already stable public api, and they rely on some sort of serialised JS command as the value, so I think it wouldn’t be a stretch to expose a to_string or encode function to Phoenix.LiveView.JS to support this use case or any other use case that escapes templates yet can still avoid the burden of having to drop down to writing js hook/bindings/whatever.

Oh. I thought it would just implement to_string already. Thinking about this some more I might have used Jason.encode!(command.ops) in the past as well. It would certainly be useful to be able to use the serialization phoenix uses.

1 Like

This is a reasonable proposal, maybe it needs to go to the Proposals section on the forum for better visibility?

There is still no official way to serialize JS commands on the server side.

Here’s an example use case of exec’ing server-pushed JS commands: Animating list items with LiveView streams - #6 by rhcarvalho.

What I found is that, in testing, JS commands are serialized using Phoenix.HTML.Safe.to_iodata(), but that HTML-encodes the input, making it not directly usable on client JavaScript (e.g. " becomes &quot;.

What works is what is described in the OP, JSON-encoding the :ops field of the struct, with no forward-compatibility guarantee.

1 Like

LiveView only exposes APIs for working with the encoded JS, so using Phoenix.HTML.Safe for serialization is the best approach, I’d say.

Thanks for the answer!

Phoenix.HTML.Safe treats the input as if it would be placed in a generic HTML context. When using JS commands within a HEEx template, the engine is smart enough to escape the resulting string according to context, most often within an HTML attribute (this is the common case that works great).

In terms of APIs for encoding JS commands on the server, are we limited to those two options?

The OP wanted to encode the value on the server side and send it down to the client via push_event that encodes the value in transit as JSON and becomes a JavaScript event, with JS payload on the client. If encoded with Phoenix.HTML.Safe, JS code could need to manually unescape HTML entities like &quot;".

I’d love if there would be a JS.to_json(_iodata) function. I know phoenix cannot implement any json library protocol given it doesn’t depend on a specific json library. But having such a function would allow anyone to implement the protocol for the library they’re using.

Ugh, sorry. I thought we’d handle unescaping in execJS, but I was mistaken. So yeah, I don’t think there’s anything that prevents us from implementing String.Chars.

You’ll still need to do write it like the following, but you can implement Jason.Encoder by just delegating to to_string and then omit the extra to_string call.

push_event(socket, "do-something", %{data: to_string(JS.add_class("foo"))})

Thank you for the PR.

I wouldn’t mind the to_string, but on second thought I realized:

  1. The Jason.Encoder implementation would need to assume to_string returns safe JSON to pass it as is instead of encoding as a JSON string; or
  2. JavaScript code would need to decode the payload string and then parse it as JSON to turn it into a valid JS command, once again assuming a JS command is valid JSON.

In this case, @LostKobrakai’s suggestion seems like a better contract. JS.to_json would naturally appear in HexDocs at the right context (module docs), be more discoverable through search, and imply JSON compatibility.


Copying from Animating list items with LiveView streams - #6 by rhcarvalho to bring a concise use case as example, both Elixir and JavaScript side:

So, @steffend, if we write JS.transition(...) |> to_string() we’d end up with a string in the JS event handler, not a deeply nested list which is a valid JS command. Either JS code would need to call JSON.parse(payload_str), or server code needs to assume the result from to_string is valid JSON as-is (which would be undocumented behavior).

A string is valid JSON - am I missing something?

Edit: it would look like this:

defimpl Jason.Encoder, for: Phoenix.LiveView.JS do
  def encode(%Phoenix.LiveView.JS{} = js, arg) do
    Jason.Encode.string(to_string(js), arg)
  end
end

And that will work with execJS.

The difference is

[["transition",{"transition":[["ease-out","duration-200"],["opacity-0"],["opacity-100"]]}]]

which is JSON.encode!(%JS{}.ops)

vs

"[[\\"transition\\",{\\"transition\\":[[\\"ease-out\\",\\"duration-200\\"],[\\"opacity-0\\"],[\\"opacity-100\\"]]}]]"

which is the string version of the above.

The LiveSocket.execJS code will parse one level of JSON encoding expecting a list:

If the content is, however, a string, it will not work.

I think the protocol implementation would have to be:

defimpl Jason.Encoder, for: Phoenix.LiveView.JS do
  def encode(%Phoenix.LiveView.JS{} = js, arg) do
    # assume valid JSON
    to_string(js)
  end
end

Good morning!

I thought about it a bit further, I think to_string/1 as in the PR is indeed a good contract (but see Option 1 vs Option 2 below).

When we write a JS command inside a HEEx template, we’re intuitively serializing it as a string in an HTML attribute (with proper escaping taken care of for us).

The new use case we’re unlocking, push_event with a JS command in the payload, requires a JSON-serializable payload. A string is JSON-serializable, and so is a map with a string value.

On the client side, LiveSocket.execJS expects an “encoded JS command” as argument. “Encoded” in this context means encoded as a string (which happens to be a JSON serialization of a list, but that is not part of the public contract, and could be anything).

If not implementing String.Chars / to_string, the alternative would be some explicit function, like the to_json mentioned earlier. The downside of JS.to_json is that it prescribes the serialization format. JS.encode/1 (taking a %JS{} as input, returning binary) would keep the underlying serialization format opaque, and is inline with the “encoded JS” terminology.

I understand implementing String.Chars can be convenient, examples:

iex(1)> alias Phoenix.LiveView.JS
Phoenix.LiveView.JS
iex(2)> JS.transition({"ease-out duration-200", "opacity-0", "opacity-100"}) |> IO.puts()
[["transition",{"transition":[["ease-out","duration-200"],["opacity-0"],["opacity-100"]]}]]
:ok
iex(3)> "#{JS.transition({"ease-out duration-200", "opacity-0", "opacity-100"})}"
"[[\"transition\",{\"transition\":[[\"ease-out\",\"duration-200\"],[\"opacity-0\"],[\"opacity-100\"]]}]]"

A potential downside is having the protocol implementation trigger unexpected behavior, e.g. serializing the JS command in unexpected contexts instead of throwing or failing to type-check at compile time. In this case, the explicit function would be, well, explicit, though less convenient.

So I think it comes down to deciding whether implementing String.Chars has any unintended consequences, and how we’d document the push_event use case.

Option 1: String.Chars

    socket
    |> push_event("exec", %{
      "selector" => "#urls-#{url.id}",
      "js" => "#{JS.transition({"ease-out duration-200", "opacity-0 scale-95", "opacity-100 scale-100"})}"
    })

Option 2: JS.encode/1 (or similar)

    socket
    |> push_event("exec", %{
      "selector" => "#urls-#{url.id}",
      "js" =>
        JS.transition({"ease-out duration-200", "opacity-0 scale-95", "opacity-100 scale-100"})
        |> JS.encode()
    })
  • Would be documented at the function level.
  • Accidentally dropping a %JS{} inside a string (i.e. not HEEx) would still produce an error as is the current behavior.

In my current thinking, I prefer Option 2 because it preserves existing behavior, is more discoverable, doesn’t impede us from implementing String.Chars in the future and is inline with what @steffend said:


@steffend I think user code shouldn’t need to implement Jason.Encoder for JS commands. It is easier to reason about passing an opaque string (“encoded JS”) to push_event.

I implemented “Option 2”, explicit encode/1 function, in a PR Add JS.encode/1 function by rhcarvalho · Pull Request #4046 · phoenixframework/phoenix_live_view · GitHub for us to have a comparison (borrowed the test updates from you, @steffend).

Reflecting upon the tests, I realized that both PRs do not guarantee that encoding the same %JS{} value multiple times returns a consistent string representation.

That’s because we’re encoding maps to JSON, thus no guaranteed order. The tests, however, are careful to sort map keys before JSON serialization.

We might opt to move that behavior from tests to the implementation, so that we guarantee a stable representation (good for tests in API consumers / LiveView users, and perhaps for reducing unnecessary diffs and churn when sending data over the wire – thinking about both server-side change tracking and morphdom patching).

Hmm even if it is decided that neither String.Chars nor JS.encode should be added to LiveView, making the current Phoenix.HTML.Safe implementation for JS stable seems like a worthy change.

Currently, recomputing some function component that contains JS commands may trigger a diff solely because of arbitrary map order.