unabridged/motion

Component with collection not re-rendering when one element changes

davidalejandroaguilar opened this issue · 2 comments

Bug report

Given a component with a collection of objects, when you update one of them, the component is not rendered again.

Description

For example, given a post list component that renders posts:

class PostListComponent < ViewComponent::Base
  include Motion::Component
  attr_reader :posts

  def initialize(posts:)
    @posts = posts
  end

  map_motion :post_list_update

  def post_list_update(event)
    post = posts.find_by(id: event.target.data[:post_id])
    post.randomize_date_within!(5.days)

    # Only by changing posts in some way do we get a re-render, even though a
    # post was changed.
    #
    posts.each(&:reload)
  end
end
<div>
  <% posts.each do |post| %>
    <button data-motion="post_list_update" data-post-id="<%= post.id %>">PostListComponent#update</button>
    <%= render(PostComponent.new(post: post)) %>
  <% end %>
</div>

When you update one of those posts, the component will not reflect the update.

I'm just trying out View Component and Motion, so please let me know if this is the expected behavior and what the actual behavior should be. I'd be happy to document this.

Reproduce the issue

I have created a demo repo here, it has a sqlite db so I think it should be ready to go. Else, just create a post. Then:

  • Click the "PostListComponent#update" button. You can see how the component re-renders.
  • Uncomment the posts.each(&:reload) line in PostListComponent and see how the component won't re-render even though a post was changed.

img

Thank you for putting together this detailed report!

I took a look at the repo, and I do think that Motion is working correctly in this case.

The reason that the component is not updating until you reload is that find_by always fetches a fresh copy of the record from the database. Since this new instance never finds its way into the state of the component (either as an instance variable itself or an entry in @posts), from the component's perspective, this is effectively an update occurring in another part of the application.

The easiest way to fix this problem is to replace posts.find_by(id: event.target.data[:post_id]) with something like posts.find { |post| post.id.to_s == event.target.data[:post_id] }. The key difference is that find will use the existing Post instance inside of @posts instead of fetching a new one from the database. Since this instance is a part of your component's state, it will automatically re-render when it changes (and your template will see the correct, updated version when it reads posts)

Another way to solve this problem is to make the component responsive to changes occurring in your data-model regardless of where or how posts are updated in your application. For this, you can use broadcasts. In this case, I would add an after_commit to Post that sends a broadcast when a post is updated (or perhaps just if: :saved_change_to_date? if you want to be thrifty). Then, in your component, you can listen for that broadcast and reload your relation.

tl;dr Motion will re-render when there are changes to a component's state. In your component, @posts is not changed after you update the database, so it doesn't re-render. To fix this, you can either update through @posts directly, or reload it in response to the underlying posts being changed elsewhere with broadcasts.

If you run into trouble getting this sorted out, let me know, and I am happy to open a PR against your demo application. 🙂

Since I think this is not a bug in Motion, I am going to go ahead and close this issue for now.

@davidalejandroaguilar Please feel free to continue the discussion here if you would like further clarification or think there is something I have missed.