Skip to content

Comments

CableReady::Updatable: automatically update the DOM when state changes (aka updates_for)#145

Merged
leastbad merged 39 commits intostimulusreflex:masterfrom
leastbad:stream_updates
Sep 29, 2021
Merged

CableReady::Updatable: automatically update the DOM when state changes (aka updates_for)#145
leastbad merged 39 commits intostimulusreflex:masterfrom
leastbad:stream_updates

Conversation

@leastbad
Copy link
Contributor

@leastbad leastbad commented Aug 20, 2021

After being really impressed with @andrewculver's exciting Sprinkles video, we decided to see how we could build similar functionality into CableReady. It directly addresses a real blind-spot in the library, which is the case when you need to update the same view for a lot of people with content customized for each person.

The big idea of this feature is that instead of rendering unique content and sending it down the wire, each subscriber gets a ping notification to fetch the current page and morphdom it into the content of the web component. This allows ActionController to do what it does best, which is show the user exactly what they are allowed to see. It's also likely to take advantage of HTTP caching.

The approach taken in this PR builds on the work already done for the stream_from helper, borrowing its secure identifier infrastructure and following the same general structure.

This solution has three significant elements. On the server, there is the CableReady::Updatable (updatable.rb) which ideally gets included in ApplicationRecord. There's also the updates_for view helper (cable_ready_helper.rb) and the web component it emits, updates-for (updates_for_element.js).

While the naming has changed, the functionality is 1:1 with the original video, with a few upgrades:

  • web component supports an optional url attribute to instuct the web component to pull updates from a different page
  • web component supports updating multiple content areas with one fetch call, so long as parameters are identical
  • model updates are now opt-in on a per-model basis
  • model updates default to "all commits, all of the time" but on allows a Symbol or Array of [:create, :update, :destroy] and if accepts a Symbol or Proc
  • has_many associations now support a enable_updates option, with the simplest value true indicating "all commits, all of the time" but customizable via Array, Symbol, Hash or Proc
  • thanks to excellent contributions from @erlingur, has_many :through associations are working great

When planning your Updates architecture, keep in mind that there are three different possible outcomes:

  • updates to an individual model instance / resource
  • creates/updates/deletes on any instance of the model aka "the index view"
  • creates/updates/deletes on collections (associations) on an instance of the owner

Let's build up an example:

class ApplicationRecord
  include CableReady::Updatable
end

class User < ApplicationRecord
  enable_updates on: [:create, :update], if: -> { id.even? }
  has_many :posts, enable_updates: ->(resource) { resource.id.odd? }
end

class Post < ApplicationRecord
  belongs_to :user
end

View code I use to test enable_updates method (aka updating from model) is:

<%= updates_for current_user do %>
  <p><%= rand(1..1000) %></p>
<% end %>
<%= updates_for current_user do %>
  <p><%= rand(1..1000) %></p>
<% end %>

This would pick up any update callback sent from the User model. I use two because it's important to see that there is only one fetch for two updates.

We can add a third instance of the helper, but instead of a model instance, we can pass a class constant to receive create and destroy callbacks. This is perfect for an index view, as it allows us to update a list of instances without getting stuck in "helper for model instance that doesn't exist yet or has just been destroyed" sinkholes:

<%= updates_for User do %>
  <p><%= rand(1..1000) %></p>
<% end %>

Here's the view code to test association Updates. It's the same, except we pass a Symbol representing the name of the model association in addition to the model instance which owns the collection:

<%= updates_for current_user, :posts do %>
  <p><%= rand(1..1000) %></p>
<% end %>

Right now, things are in good shape but there are a few ways to jump in and improve this PR:

  • tests are sorely needed and highly appreciated ✔️
  • is there a way to do enable_updates better; I have concerns that on a busy site or with a big table, running this code for every record could get bad, fast ✔️
  • is there a way to move some or all of this PR out of updatable.rb without blowing up all of the self accessors? even just figuring out how to move module ClassMethods out would make this so much lighter ✔️
  • if someone could add some begin/rescue and raise some Argument exceptions if people pass the wrong options
  • is there a way that the has_many if Procs can receive a reference to the current associated model and not just the owner/originator of the association, that'd be great eg. right now if "User has_many Post" the Procs for the posts association receive a reference to the user, but that's likely only half of the story for most Procs you'd write here
  • @hopsoft would love to hear feedback on naming of methods and everything ✔️

Procs passed to if options on the enable_broadcasts method can access the current model instance as self, so you can do things like enable_updates on: :create, if: -> { id.odd? }.

Remember, if you're testing this in your app and you change one of the if/on options in your app's model, you must refresh the browser to allow ActionCable to reconnect!

This solution requires that CableReady be initialized with the memoized consumer in your application pack or controller index. It needs to include something like:

import consumer from '../channels/consumer'
import CableReady from 'cable_ready'
CableReady.initialize({ consumer })

Thanks again to @andrewculver @erlingur @ParamagicDev @julianrubisch @fractaledmind and others who have contributed so far. Great team effort.

@leastbad leastbad added enhancement proposal ruby Pull requests that update Ruby code javascript Pull requests that update Javascript code labels Aug 20, 2021
@leastbad leastbad added this to the 5.0 milestone Aug 20, 2021
@leastbad leastbad self-assigned this Aug 20, 2021
@timhaines
Copy link

Awesome to see this coming together. 💯

leastbad and others added 4 commits August 23, 2021 04:34
* Add broadcast_class option to enable_broadcasts

* Raise ArgumentError for missing inverse_of at runtime instead of load

If there is some reason why the model class for the has_many assocation
fails to load (like a typo in the code) it will first fail when
reflecting and this ArgumentError will be raised even though
it's not the actual problem.

Co-authored-by: Erlingur Þorsteinsson <[email protected]>
leastbad and others added 14 commits August 27, 2021 03:55
* Allow compoundable to work with any GlobalId-able entity

* Make "marker callbacks" lambdas

* Extract ModelBroadcasterCallbacks class

* Extract CollectionBroadcasterCallbacks class

* Call ModelBroadcasterCallbacks explicitly

* Standardize

* Align channels[] with compoundable implementation

* Rename cable_ready_broadcast

* Rename and simplify collections

* Extract CollectionsRegistry

and bestow it with managing of and broadcasting to collections

* Make method private
* Add optional belongs_to test

* Fix optional belongs_to
Copy link
Contributor

@hopsoft hopsoft left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is slick. A big thanks to all contributors thus far. Nice work!

I did have one question/proposal regarding the public API surface area prior to final approval.

Also, one thing I want to emphasize in the docs is a strong warning regarding extensive use of ActiveRecord callbacks. As noted in the PR description... while extremely powerful, callbacks can prove to be a foot gun if not used thoughtfully.

@julianrubisch
Copy link
Contributor

Yeah agreed. We should point out how to use if conditions on enable_broadcasts effectively

@hopsoft hopsoft dismissed their stale review September 27, 2021 16:54

The conversation has been started. I'll let the team and contributors take it from here.

@leastbad leastbad changed the title Broadcast updates from model callbacks Receive morph updates from model/PORO callbacks Sep 28, 2021
@leastbad leastbad merged commit e5e6cfb into stimulusreflex:master Sep 29, 2021
@leastbad leastbad deleted the stream_updates branch September 29, 2021 06:33
@rodrigotoledo
Copy link

@leastbad you describe like

<%= updates_for current_user do %>
  <p><%= rand(1..1000) %></p>
<% end %>
<%= updates_for current_user do %>
  <p><%= rand(1..1000) %></p>
<% end %>

But the correct isn't?

<%= updates_for current_user do %>
  <p><%= rand(1..1000) %></p>
<% end %>
<%= updates_for Post do %>
  <p><%= rand(1..1000) %></p>
<% end %>

@leastbad
Copy link
Contributor Author

leastbad commented Nov 6, 2021

They are both correct, because they both demonstrate different concepts.

As I explained on Discord, the goal of the example was to demonstrate that it works fine if you have multiple content areas with the same identifier.

@leastbad leastbad changed the title Receive morph updates from model/PORO callbacks CableReady::Updatable: automatically update the DOM when state changes May 26, 2022
@hopsoft hopsoft changed the title CableReady::Updatable: automatically update the DOM when state changes CableReady::Updatable: automatically update the DOM when state changes (aka updates_for) Aug 19, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement javascript Pull requests that update Javascript code proposal ruby Pull requests that update Ruby code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants