How to turn my client-side DOM manipulations into DOM-patch aware manipulations?

I want to accomplish something similar as Phoenix.LiveView.JS, per the docs:

While these operations can be accomplished via client-side hooks, JS commands are DOM-patch aware, so operations applied by the JS APIs will stick to elements across patches from the server.

so I wonder, what’s the “trick” that makes LiveView.JS DOM-patch aware?

Because I want to use this “trick” for my own client-side DOM manipulation. I searched the source code for a while but I did not find the mechanism by which JS.operations are able to be DOM-patch aware.

Some alternatives that serve a similar purpose but feel too blunt for me:

  • phx-update="ignore": This is easy, it works mostly, but I don’t like it because the children cannot leverage LiveView events, it also feels clunky to sprinkle the HEEX template with this directive.
  • cloning nodes inside onBeforeElUpdated (docs): I don’t like this, there’s very little documentation about how the cloning function should work. It is also a tricky operation, if you clone a node then it might discard any LiveView operations that should have been applied to the node and its subtree. On top of that, it becomes necessary to “mark” with an HTML attribute the nodes that would get cloned.

It might be referring to the morphdom patching. In livebook, they set attributes and ask morphdom to leave 'em alone.

3 Likes

I was also wondering how LiveView JS commands could achieve “DOM-patch aware” DOM manipulations, so I just tried to read the source code of liveview js lib a few days ago. And here is my understanding:

When LiveView executes a JS command on the client-side, for example, a JS.set_attribute operation, it not only sets the attribute of that specific element, but also puts some private information into the element properties. It is shown in the code snippet below in the DOM.putSticky function:

And you can also inspect these private properties in Chrome DevTools.

All these executed JS commands will be stored in this phxPrivate.sticky property, so that when DOM-patching happens, these DOM changes made by JS commands will remain intact.

So if you want to achieve something similar to JS commands, here is a kind of tricky approach: just executing LiveView JS commands from JavaScript. Here’s how you can do it:

In your heex template, store JS commands in a data attribute:

<div id="my-element" data-js={JS.set_attribute({"aria-expanded", "true"})}>
</div>

And on the JavaScript side:

let liveSocket = new LiveSocket()

const el = document.getElementById("my-element")
liveSocket.execJS(el, el.dataset.js)

I think this approach is also used in GitHub - fly-apps/live_beats

1 Like

oh cool, I did not know onBeforeElUpdated was a function from morphdom, I will explore morphdom.

thanks! Looks like JS commands create a bit of an array structure, going to explore execJS next to see how that gets managed. The docs mention execJS for a brief second.

I wonder then, if I can simulate phxPrivate.sticky property with my own DOM changes, maybe not. Will investigate.

JS commands are great but right now they are heavily lacking any kind of node-creation, I am taking a similar approach to alpine of writing a <template> tag and then using Node.cloneNode, so DOM patches from the server clears my cloned nodes, even for unrelated changes because the client-side cache of the DOM does not account for my cloned nodes, as I understood from this explanation.

Yeah, currently it’s kind of difficult to synchronize client-side DOM node changes with LiveView DOM patching process.

But I’d like to recommend you know about Shadow DOM from Web Components technology.

Shadow DOM lets you create encapsulated DOM structure that can be attached to a specific element. And shadow DOM structure won’t be affected by regular DOM manipulations, that is, LiveView DOM patch won’t affect the DOM structure inside shadow DOM.

And here’s a talk about Web Components and LiveView from ElixirConf.

I don’t know if this can suit your use case, but hope that you’ll find it interesting.

1 Like

This is a bit of a crazy ramble so if it doesn’t make sense, that’s my fault…

Watched that talk, very interesting. He reaches a conclusion similar to my premise, there shouldn’t be connections to the server for merely UI changes, the difference is that he still finds liveview convenient for coordinating client-side state between separate components, I don’t share that. So far I am happy using pub/sub between separate components to keep state, I just need to get it to play nice with LV…

Here is an interesting bit I found about morphdom in the readme:

Because morphdom is using the real DOM, the DOM that the web browser is maintaining will always be the source of truth. Even if you have code that manually manipulates the DOM things will still work as expected.

which is exactly what I want, but counter-intuitively LiveView gets rid of this benefit.

The ShadowDom seems really interesting to accomplish this, I need to read more about that though.

One of the reasons I don’t like phx-update=ignore is that you are unable to use phx-update in the child nodes, although I suppose the child-nodes could use the push function to communicate with the server… but how would they receive information? they couldn’t receive diffs from the server, but these nodes might desire to send new input data events to validate, then the issue becomes receiving the server response.

Sidenote: I am overcomplicating myself if Shadow Dom has the same effects… I guess shadow dom would be more capable if it is capable of nesting normal-dom inside shadow-dom, which would be a little crazy and cool. (TODO: research this).

Event listening for server pushed events feels a bit messy because you have to keep track of the event-name and it has to be listened-to “globally” on the client-side, I wonder if I can listen to the server event that happens with a “socket assign” update in a given element (e.g. result from handle_event("validate"...), that would be what I need but I haven’t seen information about it, I think the usual render result from a live_view only sends the computed diff, no option to attach additional payload to that message.

I have not given server-pushed events a good test-run so I might come back to them later. However, making special events for every client-side component sounds a bit exhausting when ideally one would use the same “validate” event for the entire form.

So I guess, ideally there would be “assigns” that are sent to the client as plain JSON and then the client is in charge of “diffing” it (showing an error or a validation), and ideally the client JS would also be able to eavesdrop on these messages to get the values for its own operations, as opposed to the default client-side logic that simply “diffs”. So I wonder if I can receive the changeset struct from a phx-change="validate" in the client side, on top of the usual diff. I might have to explore handleEvent client hook on a beforeUpdate maybe.

2 Likes

replying to myself here:

I think the usual render result from a live_view only sends the computed diff, no option to attach additional payload to that message.

I wonder if I want to do this at the end of a handle event:

{:reply, map(), assign(socket)} and I send the additional information in the map()

per docs:

It must return {:noreply, socket}, where :noreply means no additional information is sent to the client, or {:reply, map(), socket}, where the given map() is encoded and sent as a reply to the client.

or, perhaps another option (idk if this works)

def handle_event("validate", params, socket) do
  changeset = schema.changeset(params)# blabla
  socket = socket |> assign(:changeset, changeset)
  socket = socket |> push_event("changeset_as_JSON", changeset |> Jason() )
  {:noreply, socket }
end

and then the <form> component gets the usual diff and I add a special event listener for changeset_as_JSON from push_event that handles validation for inputs that can’t be part of the diff because I created them client-side.

Feedback welcome.

Shadow DOM does have the same effects. Since nodes in Shadow DOM are in an encapsulated environment, they can no longer receive LiveView diffs anymore.

But Web Components also comes with the template and slot API that lets you slot light DOM nodes (regular DOM nodes) into shadow DOM and you can listen to slot content changes with slotchange event. Maybe this is what you want.

1 Like

I found the line that checks for the sticky attribute that you mentioned, it is this one phoenix_live_view/dom_patch.js at master · phoenixframework/phoenix_live_view · GitHub which is inside the onBeforeElUpdated function from morphdom (which is not the same as onBeforeElUpdated from LiveSocket).

this is the isPhxSticky check phoenix_live_view/dom.js at master · phoenixframework/phoenix_live_view · GitHub which simply looks for a data-phx-sticky attribute.

Very simple after all, not unlike adding phx-update="ignore", and I believe that this also stops the subtree from being checked by morphdom (per morphdom docs) but don’t quote me on that, phoenix itself could be diffing the subtree somewhere else.

1 Like

Shadow dom with custom elements is, by far, much better. No need to litter phx-update="ignore" everywhere, nor running the risk of forgetting to add it somewhere.

Just checking-in, whilst Shadow DOM is super cool, I haven’t yet totally figured out the best way to use it so I am sticking with the simplicity of phx-update="ignore", for now. Some interesting quirks:

  • with phx-update="ignore" your “ignored” nodes still trigger phx-change="validate" events (i suppose the events bubble up to the not-ignored form).
  • with ShadowDom any CSS selectors that rely on the light dom elements is broken and sort of inconvenient to restore.

However, the Web Components are an absolute pleasure to work with and my now preferred way of writing client side JS. I was able to accomplish the same UI results through AlpineJS and also through phx-hook="mount", but Web Components are so much better, thanks for pointing me in that direction. And I haven’t even explored the full potential of Web Components! The fact that they can listen to slot and other changes…chefs kiss.

Do you have any updates?

I’m trying to use data-tables from Carbon Web Components, but the JS on the components fail when I add rows to the table.

phx-update="ignore" prevents the JS from failing, but then I can’t add new rows to the table.

it is tricky because it depends on what you are doing.

if you use phx-update="ignore" then you can’t use socket assigns to add more elements, you would need to use push_event on the server with the new data, and then you would have to add JS on the client to append those items.