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?


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:

|> 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>

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.