Provide reliable way to avoid LV to erase DOM Attributes set by JS

Hello,

i would like to expose a issue i am currently facing , and it my mind can have a solution as Quality of Life on using Phoenix together with javascript ecosystem.

Preface

Some UI integration doesn’t need to be kept on the Server itself, causing just latency. I don’t wanna that to open a dropdown the end user should wait this:

[ Click ] -> [ Send Event to Server ] -> [ Compute new DOM ]
-> [ Send DIFF to the browser ]

It’s very expensive computation to just deal with a dropdown opening…

Issue

To eliminate the need for server round-trips, and keeping state on the server state.
I can use component’s Hooks API to deal “no-critical-ui” interaction with the UI.

However, this approach sometimes conflicts with Phoenix’s synchronization and diffing mechanism for rendering live components. The most common case i can recall is the usage of <dialog /> HTML tag which isn’t kept open across rerendering.

I prepare a Draft MVP with a very rushy patch which implement my solution, and a repo containing the “issue” and relative patch with the PR phoenix version.

PR: fix: Draft Proposal API by salvatorecriscioneweb · Pull Request #3574 · phoenixframework/phoenix_live_view · GitHub
Issue: issue-liveview-deps-patchDOM/main_bugged.exs at main · salvatorecriscioneweb/issue-liveview-deps-patchDOM · GitHub
Fixed with PR’s API: issue-liveview-deps-patchDOM/main_patched_example.exs at main · salvatorecriscioneweb/issue-liveview-deps-patchDOM · GitHub

    const myButtonHook = {
        mounted() {
          // This is the important part
          this.ignoreDOMPatchAttributes(["data-times-clicked"]);
          // End Important Part
          this.el.dataset.timesClicked = 0;
          this.el.addEventListener("click", e => {
            this.el.dataset.timesClicked = (parseInt(this.el.dataset.timesClicked) || 0) + 1;
          });
        },
        updated() {
          console.log("Button updated", this.el.dataset.timesClicked); // Now this works
        },
    };
Real World Example

I have my Normal component, which renders a dropdown, the dropdown state is kept using data-state="open|close" from the JS hook, and it’s placed inside a live component.

This results in every update ( handled by example handle_event ) on the triggering an update to the live component, which resets my data state to the component’s default state.

The solution seems simple, doesn’t it?

Already exists some workaround to this issue:

phx-update=“ignore”

While that might work, it make the target’s HTMLNode children not able of adapting to changes.

Dealing inside the update hook function

This can be good solution until the changes is quite simple, like the example, but in case of complex solutions ( Image a Javascript library which animate the element using the style attribute) is not valid solution anymore…

Patching app.js

I could apply a workaround by implementing a custom patcher, something like this in the app.js in phoenix

dom: {
    onBeforeElUpdated(fromEl, toEl) {
      const isPopover = fromEl.getAttribute("phx-hook") == "Popover";
      if (isPopover) {
        for (const attr of fromEl.attributes) {
          if (attr.name == "data-open") {
            toEl.setAttribute(attr.name, attr.value);
          }
        }
      }
    },
},
Notify back the server about the change

In my case, this could also serve as a potential solution, but it necessitates storing the dropdown state in the server session (live_component) an unnecessary detail for the LiveView itself ( dropdown open or close ) and a waste of resources, this is how actually most of the components in the ecosystem works from what i saw in my experience.

Real Issue behind all these solutions

Although these solutions might work individually, they fall short when my component is offered as a part of an library. I didn’t found a reliable way to expose them or ensure they work out of the box, for instance, from my hook.

This issue often prove unreliable for interactivity and animation libraries, which may rely on the style attribute of a node, animations are reset or broken due to LiveView updates, creating conflicts with the library’s functionality.

Something which is part of the Phoenix API is hardcoded patched for this issue, for example: assets/js/phoenix_live_view/dom.js#L318

Possible solution or proposal

It would be amazing have something similar to the this.handleEvent, capable of specify how the component is patched during re-rendering with MorphJS.

In my mind, something like this could provide a highly effective tool for addressing this issue, while preserving the advantages of live components

// My Dropdown UI Interactivity Hook
export default {
   mounted() {
	   this.handleDOMPatch((from, to) => {
			for (const attr of fromEl.attributes) {
		        if (attr.name.startsWith("data-open")) {
		            toEl.setAttribute(attr.name, attr.value);
		        }
	        }
	   });
	   // Or Simpler version
	   
   }
   ...
}

-another example-

// My Animated Component
const fancyJavascriptAnimationLibrary = require("fancy-js");

export default {
	mounted() {
	   this.handleDOMPatch((from, to) => {
			for (const attr of fromEl.attributes) {
		        if (attr.name == "style"){
			        // Keep Style consistent across the updates
		            toEl.setAttribute(attr.name, attr.value);
		        }
	        }
	   });
	   fancyJavascriptAnimationLibrary.animate({scale: {to: 1, from: 0}});
   }
   ...
}

This function should be eval exclusively for this specific DOM node identified by its ID (hooks require them so not a issue), similar to the this.handleEvent API.

-or- I did a fast patch on LV Code to supports this

Additionally, a declarative approach works, aligning more closely with the others API Phoenix.

<button
	id="button-with-animation"
	...
	phx-ignore-attrs="style,data-animation-*"
>
	Fancy Button
</button>

and in the DOM Patcher we keep the attributes which match with ignoreAttrs dataset.

Any thoughts? Is there a reliable possible solution that may already be implemented, one that I might have missed it?

Thanks for reading

1 Like

Hey @nsalva,

sorry for the late reply!

That’s what JS commands are for. They provide a way to perform “sticky” operations that are always re-applied to DOM elements. Since LV 1.0.0-rc.7, those are also available to Hooks:

const myButtonHook = {
  mounted() {
    const that = this;
    this.js().setAttribute(this.el, "data-times-clicked", 0);
    this.el.addEventListener("click", e => {
      this.js().setAttribute(this.el, "data-times-clicked", (parseInt(that.el.dataset.timesClicked) || 0) + 1);
    });
  },
  updated() {
    console.log("Button updated", this.el.dataset.timesClicked);
  },
};

For more details, see: phoenix_live_view/assets/js/phoenix_live_view/view_hook.js at c44c48e09c3705fd9510d491d77f5237929be08e · phoenixframework/phoenix_live_view · GitHub

2 Likes