phoenix_live_view
phoenix_live_view copied to clipboard
LiveView page rendered with two nested levels of LiveComponents has the component hooks' mounted callbacks invoked in an unexpected manner - a bug?
Environment
- Elixir version (elixir -v): 1.10.3
- Phoenix version (mix deps): 1.5.9
- Phoenix LiveView version (mix deps): 0.15.7 (all 0.15.x versions, haven't tested with the 0.14 and earlier)
- NodeJS version (node -v): 12.14.1
- NPM version (npm -v): 6.13.4
- Operating system: Linux MInt
- Browsers you attempted to reproduce this bug on (the more the merrier): FF, Chrome
- Does the problem persist after removing "assets/node_modules" and trying again? Yes/no: Yes
Actual behavior
The components in the page are nested in two levels as in the example shown below:
LiveView template:
<div id="page">
<div id="top-list" phx-update="replace" phx-hook="TopHook">
<%= for component_assigns <- @level1_assigns do %>
<%= live_component @socket, Level1Component, component_assigns %>
<% end %>
</div>
</div>
Level1Component template
<div id="level1-<%= @id %> phx-update="replace" phx-hook="Level1Hook">
<%= for component_assigns <- @level2_assigns do %>
<%= live_component @socket, Level2Component, component_assigns %>
<% end %>
</div>
Level2Component template:
<div id="level2-<%= @id %> phx-hook="Level2Hook">
<%= render_content( assigns) %>
</div>
The rendered DOM looks perfect and all ids are unique and hooks are all working. But, the Level2Hook mounted callbacks get always invoked first, i.e. before the TopHook's and the Level1Hooks' mounted callbacks.
This very same structure but fpr a completely different LiveView page (with many other LiveComponents) has no such problem, i.e. it works as expected with the mounted callbacks invoked top-down in the element rendering order (first the top container's hook, then the first level1 hook, then level2 hooks for each of the level2 components belonging to the first level1 component, etc.)
Expected behavior
The order in which LiveView invokes the mounted callbacks should be deterministic (always the same, relative to the top-down order of elements rendered) as is the case with the LiveView page described as working as expected.
Given the issue only happens in certain cases, can you please isolate it in an app where we can reproduce it? Thanks!
If you can provide a full example of the "This very same structure but fpr a completely different LiveView page (with many other LiveComponents) has no such problem," then I could say more, but we mount the hooks in the order they are added to the DOM, so I'm not sure what exactly could be happening here without seeing both the full live views and components.
I'll try.
@chrismccord
Btw, is there a chance that the very order in which the elements are added to the DOM is not always necessarily top-down (down the descendants line)?
Here's the catch. I was encoding a part of the Level2Component id so that it also contained HTML id-unsupported characters. That was my oversight.
However, when this is the case, LiveView does not report an error but rather renders the ids as such and then renders and/or mounts those components first (i.e. before their ancestors).
When using HTML id-supported set of characters, the bug no longer occurs.
I have to reopen this issue for it seems it is quite real after all and it's not as harmless as initially thought.
The problem with ids and the order of mounting nested components is that LiveView seemingly expects that assigns provided to the templates contain the same element ids when mount/3 is called the second time as when it is called for the first time before the static page is rendered. I say seemingly, because I couldn't find it mentioned in the docs, while it looks to be the reason behind the before stated unexpected behavior.
Namely, when generating my sample data structure and the resulting assigns by creating a fixed number/fixed content of data items with the ids generated as random numbers, the resulting assigns will be identical in the first and the second mount/3 call in everything but their ids. This is different from when reading from the DB when, given the short time interval between the two mount/3 calls, the content will almost always be completely identical for the two calls. However, if for some reason, the state changes in-between the two mount calls, the unexpected behavior will take place (and will be extremely hard to trace).
Now, the said unexpected behavior would be easy to circumvent by simply not loading (or generating anew in the case of my work in progress example) the underlying data structure in the second mount/3 call but since there is no way to assign the loaded data on the first (static) mount/3 call, this is not an option.
On my work-in-progress live-view page I currently avoid the bug from happening by using id -> dom id mapping where dom ids are always generated in a same sequence, but that too will fail if the underlying data model changes in between the two mount/3 calls.
Do you still need a separate working sample app to track this bug down?
Do you still need a separate working sample app to track this bug down?
Yes, please!
Keep in mind what you might be seeing is expected because as we patch the DOM, nodes that existed in the tree previously are not removed, and likewise, new nodes get added by the order we encounter a new node while patching. So if you are sending random dom IDs down, it would be expected that the order is not the same because the nodes may or may not be added/removed/shuffled. Without a clear demonstration app of the behaviour it's hard to say if this is a bug as it doesn't sound unusual atm
Ok, here's the repo link: https://github.com/DaTrader/cards_demo
URL: localhost:4000/dealing
When installed, take a look at the DeckLive.build_dom_map/1 function. In there there's a list-based and a MapSet-based comprehension alternatives. With the MapSet based option (the not commented one by default), the console dump will show this 'unexpected' behavior. If you comment it and use the alternative list-based comprehension instead, the console dump mounting logs will be perfectly top-down.
As what Chris suggests above, the reason behind this issue is most probably the fact that LiveView is effectively diffing in-between the first and the second mount/3 call. But, please, check the demo yourself and let me know if that is so.
Now, if that is indeed the case, what in your opinion is the proper solution to avoid the side effect of having the hook mounted callbacks invoked in undesired order (an order different than strict top-down DOM element-wise)?
We can send some connected? based instruction to our server-side logic to fetch the stale state and thus avoid the diffing in between the mounts, but that seems wrong.
Given the fact that the JS code wasn't given the chance to react upon the first (static) mount, it is only the second mount that it sees as its first so my thinking here is that regardless of the diffing between the two, the order of the mounted callbacks received should always be the same i.e. top-down.
What say you?
I'd like to expand further on this for I've just noticed another related problem while using LiveView together with a responsive framework in my app.
This other framework is setting its own attributes and classes to containers tagged as responsive (a JS-based solution to the still non-existent container queries). Dealing with LiveView overriding those on updates is trivial by restoring the original element properties with the beforeUpdate/updated callbacks and it works perfectly.
However, the fact that the JS Hook code is oblivious of the initial (static) mount rendering, makes it impossible to guess what responsive container properties are changed (by the responsive framework) after the first mount, leading to undesired visual side-effects when opening the page.
The easiest workaround here that I am now using is simply not to render if not connected? i.e. to return an empty ~L"" instead of rendering the page. But this is a workaround the consequence of which is that the page will never get rendered statically.
How about a symetry with the LiveView server side and exporting a function akin to connected?/1 in LiveView JS and then having the Hook callbacks invoked even for the first mount/rendering (if JS is enabled) so that it becomes a developer's responsibility to decide what to do then? Please note that this would solve the originally posted issue here, for then the diffing done by LiveView b/w the first and the second mount would be detectable by our JS hook code.
Hi @DaTrader, sorry this went so long unanswered. Can you please update the app to latest LV and let us know if the issue still exists? Thank you.
Closing this issue for being obsolete given the time lapse and all the changes LiveView has undergone in the meantime. If the issue resurfaces again, I will be happy to reopen it.