Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

phx-hook does not run in elements dynamically added to the DOM #2563

Closed
viniciusmuller opened this issue Mar 28, 2023 · 20 comments
Closed

phx-hook does not run in elements dynamically added to the DOM #2563

viniciusmuller opened this issue Mar 28, 2023 · 20 comments

Comments

@viniciusmuller
Copy link

viniciusmuller commented Mar 28, 2023

Environment

  • Elixir version (elixir -v): Elixir 1.14.3 (compiled with Erlang/OTP 25)
  • Phoenix version (mix deps): 1.7.2
  • Phoenix LiveView version (mix deps): 0.18.18
  • Operating system: Linux debian 5.10.0-21-amd64 #1 SMP Debian 5.10.162-1 (2023-01-21) x86_64 GNU/Linux
  • Browsers you attempted to reproduce this bug on (the more the merrier): Firefox, Chrome
  • Does the problem persist after removing "assets/node_modules" and trying again? Yes/no: Yes

Actual behavior

Currently, when an element is dinamically added to the DOM and it has a phx-hook attribute, the hook is ignored and not run.
An example use case where this behavior is useful is implementing the Portal component in the swish component library, which allows an user to mirror an element as a child of another DOM element (such as body), making it easy to create overlaid components, such as dialogs, modals and toasts.

Steps to Reproduce

// Simple "Hello" hook in app.js

let Hooks = {}

Hooks.Hello = {
  mounted() {
    console.log("Mounted!")
  }
}
<%# HTML Template %>

<button id="clone" class="bg-teal-400 rounded p-1">
  Clone
</button>

<template id="temp">
  <div id="hook" phx-hook="Hello"></div>
</template>

<div id="hook-2" phx-hook="Hello"></div>

<script>
  document.querySelector("#clone").addEventListener("click", e => {
    let template = document.querySelector("#temp")
    let clone = template.content.cloneNode(true).firstElementChild;
    document.body.appendChild(clone);
  })
</script>

One can see that after clicking the clone button, an element containing a phx-hook attribute is mounted into the DOM but the hook does not run.

Expected behavior

After mounting an element that has a phx-hook attribute into the DOM, the hook should be executed.

It is also interesting to note that other phx attributes, such as phx-click continue to work on the dynamically mounted element, whereas phx-hook doesn´t.

@josevalim
Copy link
Member

I don’t think this can work in practice without additional code. phx-click works because we can listen in all clicks. Listening to all page change events would be expensive. We could maybe provide an API to mount hooks that have not been mounted yet, and you would call it when necessary.

@thiagomajesk
Copy link
Contributor

thiagomajesk commented Mar 28, 2023

Hi @josevalim! Thanks for the quick reply!

I think that having a way to force-mount the hooks manually would already allow us to achieve what we need.
For instance, our Portal implementation has to clone a given template on the DOM and place it somewhere else.

We could "activate" it manually here if we have this special API and I'd hope everything works as expected:
https://github.com/thiagomajesk/swish/blob/master/assets/hooks/portal.js#L68

// Clones the template that we want to "teleport" somewhere else
this.clone = this.el.content.cloneNode(true).firstElementChild;
// Inserts the clone into the DOM here (after the body for instance)
// "Activate" the element and makes bindings work 
superDuperPhoenixMethodThatMakesThisWork(this.clone)

I just want to be sure that if we do this, all bindings would work as expected; like the element was available from the page load. Otherwise, we would have to resort to a third-party library like Alpine; losing many advantages of using just LiveView constructs. Besides making the configuration a little more convoluted for users, I think the user experience wouldn't be the same overall.

Update: Another alternative could be adding a phx-dynamic or phx-template binding that listens when a specific element gets placed in the DOM and "activates" it automatically for us. I imagine this might be useful for other use cases as well.

@josevalim
Copy link
Member

Feel free to attempt a PR to explicitly mount hooks.

@thiagomajesk
Copy link
Contributor

thiagomajesk commented Mar 28, 2023

Cool @josevalim! Do you have an idea of how this API would work? We are not that familiar with the LiveView code base, but it would be a cool exercise nonetheless.

PS.: I appreciate if you have any pointers for us as well. Cheers!👍

@chrismccord
Copy link
Member

What are you doing within your hook? I need to hear more about the use case because opening this up will necessarily be fragile if we change the way lifecycle works for hooks on the client. For example, if you're already using js to build DOM containers and whatnot, you can wire up your custom js listeners/logic in the same place without the hook, no?

@thiagomajesk
Copy link
Contributor

thiagomajesk commented Mar 29, 2023

Hey @chrismccord!

We have a Portal implementation that uses a template tag to hold its contents: https://github.com/thiagomajesk/swish/blob/master/assets/hooks/portal.js.

If you are not familiar with what a Portal is: It's simply a way to render elements outside of their parent tree, making rendering overlayed items like dialogs and modals a lot simpler (because of CSS constraints).

Feel free to browse the project, but the gist of the problem is this:

We are currently implementing a toast element that you can see here: thiagomajesk/swish#3.
Toasts are rendered inside a viewport (a Portal), which is a template tag with a hook.

A Toast also needs to have a hook to hold some state for closing it (see previous discussion here: #2550).

Since the contents of the template are not rendered in the DOM yet; by the time LiveView runs its logic, it does not register the hooks. So, when the contents of the Portal (template) are placed in the body, the hooks that exist in that Node's tree are not mounted. This is an example of what is going to be rendered into the HTML:

<!-- This hook controls where to place the portal,
how to open and how to dispose of the portal after it's closed.
 -->
<template phx-hook="Swish.Portal">
   <!-- This hook controls how long the toast remains open,
   listens for user interaction, and delays the closing of the toast.
   -->
   <div phx-hook="Swish.Toast" phx-mounted={show(@toast) |> hide(@toast)}>
    Hello World! I'm a very cool toast
   </div>
</template>

So, the content of a Portal is only part of the DOM when the Portal is "opened". For Toasts, this happens after a few milliseconds after the page loads and for Modals, this happens after a user interacts with a trigger. We currently have no way to make LiveView register and exec the hooks/ bindings from those elements.

@viniciusmuller
Copy link
Author

What are you doing within your hook? I need to hear more about the use case because opening this up will necessarily be fragile if we change the way lifecycle works for hooks on the client. For example, if you're already using js to build DOM containers and whatnot, you can wire up your custom js listeners/logic in the same place without the hook, no?

In theory one could define custom callbacks for the toast component and make the portal component try to send maybe a toast:open event to its children, but by doing so we end up coupling the portal component to the behavior of other specific components. And also, the portal component is supposed to be generic, so the hooks should work even if the its children is not necessarily a Swish's component.

So it would be nice to at least be able to run the hooks manually, otherwise even if we manually implemented our library's functionality using some "homemade hooks", the behavior would be wrong for the library users.

@viniciusmuller
Copy link
Author

Hey @chrismccord, I managed to get this behavior of manually mounting elements and running hooks by using the following piece of code in our portal component:

  handleOpen() {
    this.clone = this.el.content.cloneNode(true).firstElementChild;

    // Opens the portal and teleports clone to target.
    // Await a little before opening so animation ca be properly displayed.
    updates[this.update](this.target, this.clone);
    
    // forcefully run hooks (very similar to the execNewMounted function)
    this.clone.querySelectorAll("[phx-hook]").forEach(el => {
      window.liveSocket.main.maybeAddNewHook(el)
    })

    this.clone.querySelectorAll("[phx-mounted]").forEach(el => {
      window.liveSocket.main.maybeMounted(el)
    })

    // Await until next tick to register the forwarded events
    forwardEvents(this.el, this.clone)
  },

Everything is working as expected, but this approach does not feel solid, it would be much better if we had a stable and documented function/API that one could use without fearing for breaking changes.

Do you think something like this would be possible? I think that no component lifecycle changes are required in order to achieve this. Also, it would be much better if we could stop depending on the LiveSocket object, but I'm not sure if that would be possible.

One possible implementation would be making the execNewMounted function receive the element as a parameter instead of referencing this.el directly, but maybe we should have a better separation for internal/external JS APIs. What do you think?

@thiagomajesk
Copy link
Contributor

Hi @chrismccord, @josevalim! Sorry to bother... Do you think we have a course of action for this issue? We stopped developing the library for the time being until it's clear how we can properly tackle the problem.

Like @viniciusmuller pointed out, we managed to test the solution by using this workaround and it kinda works. However, we are currently blocked by using this unofficial API.

Hope we can get more information so we can proceed.

Cheers!

@viniciusmuller
Copy link
Author

Hey @josevalim @chrismccord, are there any updates on this? We would like your feedback to continue planning an API for and validating this issue's idea.

@chrismccord
Copy link
Member

It's going to take a while for me to get to this one as I have several other things to get through on the backburner. In general I am hesitant to support this since by definition it's for client-controlled DOM, yet we want to attach server generated things to it, which could be brittle based on this assumption not being used anywhere else today. Once I have some time to review this I will ping the issue/PR. Thanks!

@chrismccord
Copy link
Member

I'm inclined to wait on this one because it's an interface that's likely to tie us down in the future. I'm not yet convinced that client controlled DOM should be commingled inside the LiveView DOM patch lifecycle like this, so it would be best to solve things on your JS side. Thanks!

@thiagomajesk
Copy link
Contributor

Hi @chrismccord! Do you have any suggestions on achieving this on the "JS side"?

As far as I remember, there's still the limitation that we can't mount the hooks after the first load - which renders the usage of hooks pointless for this use-case. So, for the time being, this means we won't be able separate concerns for components whose logic resides in a hook because there's no way to mount them "on demand".

PS.: I think that activating hooks like this could become a fairly common use case now that we have streams - like activating content (eg.: a column counter) added dynamically (eg: row added to a table).

@chrismccord
Copy link
Member

hooks are mounted any time their element first appears in the DOM, so this won't be a problem for streams or anything that is added after the initial load like live navigated elements, etc.

@thiagomajesk
Copy link
Contributor

thiagomajesk commented May 12, 2023

hooks are mounted any time their element first appears in the DOM, so this won't be a problem for streams or anything that is added after the initial load like live navigated elements, etc.

Now I'm confused by what you mean 🤔... I was under the impression that this was the problem we were facing. We add a new element to the DOM, but the hook is not mounted - do streams handle this differently?

I wonder if I haven't explained the problem correctly then, imagine this:

  • There's a template tag that can contain any content inside, in this case, it contains a component that uses phx-hook.
  • We "copy" the content from the template and add it somewhere else (perhaps like a stream adding a row to a table!?)
  • We expect that the hook mounts when the element first appears in the DOM, but it's not

Perhaps I'm misinterpreting what you mean by "hooks are mounted any time their element first appears in the DOM"? What am I missing here that is different from the way you described streams work? (Just to be sure we are talking about the same thing).

@josevalim
Copy link
Member

josevalim commented May 12, 2023

Every time the server sends something, if there are new hooks, they should be mounted. There is nothing special for streams to make it work. Afaik It is part of the morphdom on traversal.

@chrismccord
Copy link
Member

The DOM nodes that liveview adds are mounted properly. We do not use mutation observer or anything to track arbitrary changes to the DOM itself. Opening up what you are requesting is a big maintenance burden and something that is likely to change in the future if we for example change the way our DOM patching or hooks work. I'm inclined to wait until this comes up more in the future before committing to it. Thanks!

@thiagomajesk
Copy link
Contributor

thiagomajesk commented May 12, 2023

Every time the server sends something, if there are new hooks, they should be mounted. There is nothing special for streams to make it work. Afaik It is part of the morphdom on traversal.

Oh, so Hooks added "dynamically" work because the server is sending content. So our problem here is because we are doing this from the client- side, got it 👍 (I wonder if in the future we could do something special for this type of case where one wishes to use templates to render dynamic content).

The DOM nodes that liveview adds are mounted properly. We do not use mutation observer or anything to track arbitrary changes to the DOM itself. Opening up what you are requesting is a big maintenance burden and something that is likely to change in the future if we for example change the way our DOM patching or hooks work. I'm inclined to wait until this comes up more in the future before committing to it. Thanks!

@josevalim's reply helped, and yours was even better, thanks for the patience @chrismccord! 😉👍

@dvic
Copy link
Contributor

dvic commented Aug 21, 2023

The DOM nodes that liveview adds are mounted properly. We do not use mutation observer or anything to track arbitrary changes to the DOM itself. Opening up what you are requesting is a big maintenance burden and something that is likely to change in the future if we for example change the way our DOM patching or hooks work. I'm inclined to wait until this comes up more in the future before committing to it. Thanks!

@chrismccord I've hit a concrete related usecase that might be interesting here:

With infinite scrolling, I want it only to activate after the user first clicked "Load more" (so that scrolling to the footer is still possible). Currently, this is not possible with the built-in phx-viewport bindings because the InfiniteScrolling hook is not loaded when the element updates. In this example clicking "Load more" sets scrolling to true and phx-viewport-bottom={@scrolling && "next-page"}. Is this something worth considering? Making hooks load when elements update, not just on mount?

@dvic
Copy link
Contributor

dvic commented Aug 21, 2023

By the way, I noticed a difference between user and private hooks: private hooks are initialized when a node is added and before update, and user hooks are initialized when a node is added. Is this on purpose?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants