Don't mix Forwardable and ActiveSupport::Delegate

apotonick

Nick Sutterer

Posted on December 2, 2021

Don't mix Forwardable and ActiveSupport::Delegate

A few days ago I released the disposable gem minor line 0.6.0. The goal was to remove unnecessary coupling to the old uber gem that was once planned to hold a set of abstractions we need in all Trailblazer gems.

What I mainly worked on was removing Uber::Delegate and supersede it with Ruby's built-in Forwardable. We simply need to delegate a bunch of methods from one object to another, and Forwardable does just that.

Image description

So what the new code basically did was to extend the Disposable::Twin class and use def_delegators to create automatic delegations.

class Disposable::Twin
  extend Forwardable # this used to be Uber::Delegate

  def_delegators :model, :title, :title=
  def_delegators :model, :email, :email=
Enter fullscreen mode Exit fullscreen mode

Obviously, removing gem dependencies, even if they're your own, feels good, so I pushed this new gem after running tests in two projects and leaned back in my pricey office desk.

Here comes another delegate!

A day later we received bug reports from several users. All of them were using Reform form objects, the problem was thrown from Reform::Form classes they had in their app.

ArgumentError:
   wrong number of arguments (given 2, expected 1)
 # ruby/3.0.0/forwardable.rb:133:in `instance_delegate'
Enter fullscreen mode Exit fullscreen mode

The developers reported that before the upgrade to Disposable 0.6.0, code as the following worked fine.

class CreateForm < Reform::Form
  delegate :assignment, to: :model
end
Enter fullscreen mode Exit fullscreen mode

After the upgrade, this would break with the ArgumentError illustrated above. My first suspicion was that this is some code not ready for Ruby 3, as this kind of exception is often observed in older code that runs with Ruby 3.

Delegate or delegate?

An hours going through the Forwardable code and playing with the broken example app provided by some nice user didn't bring any progress.

At some point I realized that what the users were actually using to create delegations was the delegate method not from Forwardable but from ActiveSupport::Delegate. The docs enlightened me that this module mixes a method #delegate into the class - the method that was used by all users reporting the error.

Order matters!

It feels really stupid in hindsight, and it took at least two hours to understand the problem.

Here's the chain of sparks that lead to fixing the problem.

  1. A Reform::Form class inherits from Twin::Disposable. This means that each Form class gets Forwardable methods mixed in. Of course, only after upgrading disposable to 0.6.0 where we automatically included Forwardable into Twin.
  2. Before 0.6.0, the former delegation from Uber::Delegate only mixed in one method called #delegates (mind the s).
  3. The Forwardable module includes a method called #delegate.
  4. Our nasty ActiveSupport::Delegate module also has a method called #delegate - just like Forwardable!
  5. In some Rails apps, delegation from ActiveSupport is automatically included into all classes.

The problem we were facing was that ActiveSupport and its delegation code was included automatically into the Reform::Form class before the disposable gem included Forwardable and hence the latter overriding the #delegate method you are expecting.

In other words, Forwardable killed ActiveSupport's #delegate method as it was loaded and included later.

Problem solved

We decided to removed Forwardable from disposable for now and fixed the obscure issue. When using delegate in a form it now refers to ActiveSupport's implementation.

💖 💪 🙅 🚩
apotonick
Nick Sutterer

Posted on December 2, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related