Overriding Rails' field_error_proc

I always wondered how to set bypass ActionView::Base.field_error_proc on certain forms while leaving it alone for others. It defaults to wrapping form fields with div.field_with_errors, but I sometimes want to handle errors a bit differently.

In my case, I'm working on a Rails engine that includes a custom form builder. It has custom form error handling and doesn't rely on the proc at all, so I wanted to bypass it when the developer is using my form builder but leave it alone otherwise.

It was surprisingly involved! My first tries didn't work out. I'll go over how I figured out where to look, but feel free to skip the investigation and jump to the solution at the bottom, step #7, or see the end result.

1. Where's field_error_proc being called?

Working backwards, I did a quick grep (using ripgrep) for that proc and found where it's being invoked.

# action_view/helpers/active_model_helper.rb
module ActionView::Helpers::ActiveModelInstanceTag
  def error_wrapping(html_tag)
    if object_has_errors?
      Base.field_error_proc.call(html_tag, self)
    else
      html_tag
    end
  end
end

I then looked around to see what called #error_wrapping, but it wasn't too helpful—they were tag and content_tag (and one other place, but that isn't important here), but they aren't the methods you're most likely to be used to using (that would be ActionView::Helpers::TagHelper#tag).

2. Where's ActiveModelInstanceTag being included?

I did another quick search which brought me a step closer

# action_view/helpers/tags/base.rb
class ActionView::Helpers::Tags::Base
  include Helpers::ActiveModelInstanceTag
end

I then checked the directory to see if I could find the classes that inherit from that, and bingo! A bunch of classes including:

module ActionView::Helpers::Tags
  # action_view/helpers/tags/label.rb
  class Label < Base
    # ...
  end

  # action_view/helpers/tags/text_field.rb
  class TextField < Base
    # ...
  end
end

3. What uses TextField?

I searched for TextField this time and found quite a few results, including:

# action_view/helpers/form_helper.rb
module ActionView::Helpers::FormHelper
  def text_field(object_name, method, options = {})
    Tags::TextField.new(object_name, method, self, options).render
  end
end

4. What calls text_field?

At this point, I thought I found the last piece; I thought that this was f.text_field. This assumption turned out to be incorrect. I jumped into working on a solution, but I ran into some errors that made me realize I had to dig a bit further.

I didn't know where to look next, so I decided to look from the opposite direction.

Rails's form_for helper accepts a builder: option which lets me define a custom form builder with custom methods that could be, like f.custom_method. I figured it was reasonable that that class might define the method I was looking for.

I found this a little differently from the steps before. I created a custom form builder class like that documentation above mentioned, and I called it. I opened up the Rails console and tried to find where the method was defined.

[1] pry(main)> ls ActionView::Helpers::FormBuilder
Object.methods: yaml_tag
ActionView::Helpers::FormBuilder.methods:
  _to_partial_path  field_helpers  field_helpers=  field_helpers?
ActionView::Helpers::FormBuilder#methods:
  button                    fields_for                 password_field
  check_box                 file_field                 phone_field
  collection_check_boxes    grouped_collection_select  radio_button
  collection_radio_buttons  hidden_field               range_field
  collection_select         index                      search_field
  color_field               label                      select
  date_field                month_field                submit
  date_select               multipart                  telephone_field
  datetime_field            multipart=                 text_area
  datetime_local_field      multipart?                 text_field
  datetime_select           number_field               time_field
  email_field               object                     time_select
  emitted_hidden_id?        object=                    time_zone_select
  field_helpers             object_name                to_model
  field_helpers=            object_name=               to_partial_path
  field_helpers?            options                    url_field
  fields                    options=                   week_field
[2] pry(main)> ActionView::Helpers::FormBuilder.instance_method(:text_field).source_location
=> ["action_view/helpers/form_helper.rb", 1906]

Perfect! Let's see... some metaprogramming. At least there's a helpful comment!

# action_view/helpers/form_helper.rb
class ActionView::Helpers::FormBuilder
  (field_helpers - [...]).each do |selector|
    class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
      def #{selector}(method, options = {})  # def text_field(method, options = {})
        @template.send(                      #   @template.send(
          #{selector.inspect},               #     "text_field",
          @object_name,                      #     @object_name,
          method,                            #     method,
          objectify_options(options))        #     objectify_options(options))
      end                                    # end
    RUBY_EVAL
  end
end

Again using the console, I verified that the "text_field" method that was being invoked via send was the first text_field method we came across in step 3.

That's it, we went through everything. Let's go through what what we found out.

5. Recap

  • form_for yields an instance of ActionView::Helpers::FormBuilder
  • ActionView::Helpers::FormBuilder#text_field calls
  • ActionView::Helpers::FormHelper#text_field which instantiates
  • ActionView::Helpers::Tags::TextField which inherits from
  • ActionView::Helpers::Tags::Base which includes the module
  • ActionView::Helpers::ActiveModelInstanceTag which defines the method
  • #error_wrapping, which checks if there are errors and calls
  • ActionView::Base.field_error_proc

Bypassing the proc will be a little hard to accomplish because we lose some context going through each layer/abstraction. We'll have to find a way to communicate from form_for all the way down to #error_wrapping.

6. Try out a solution (spoilers: it didn't work)

In Ruby, it's pretty easy to redefine an instance method on a specific instance without affecting any other instance.

I tried overriding error_wrapping to have it always return the original HTML without checking if there was an error.

class CustomFormBuilder < ActionView::Helpers::FormBuilder
  def initialize(*)
    super

    # We're adding this private method to the instance (the inner eval is the
    # important one)
    @template.class_eval do
      private
      def ignore_field_error_proc(instance)
        instance.class_eval do
          def error_wrapping(html_tag)
            html_tag
          end
        end
      end
    end

    # Here, we're defining methods like in: action_view/helpers/form_helper.rb
    # As you can see though, the implementation is quite different. Instead
    # of calling `@template`'s `#text_field` method, I reimplemented that method
    # here to "compress" two of the layers we went through into one
    @template.class_eval do
      def text_field(object_name, method, options = {})
        instance = ActionView::Helpers::Tags::TextField.new(object_name, method, self, options)
        ignore_field_error_proc(instance)
        instance.render
      end
      # We'll have to override each form builder method that we care about
    end
  end
end

Like I mentioned, this didn't quite work. The custom form builder did do its job, but it leaked and affected all following forms as well.

Since we're mutating the @template object, which is shared and passed around quite a lot, this custom form builder affects all following f.text_fields.

<%= form_for(@user) do |f| %>
  <%= f.text_field(:name) %>
  # gives us: <div class="field_with_errors"><input ... /></div>
<% end %>

<%= form_for(@user, builder: CustomFormBuilder) do |f| %>
  <%= f.text_field(:name) %>
  # gives us: <input ... />
<% end %>

<%= form_for(@user) do |f| %>
  <%= f.text_field(:name) %>
  # gives us: <input ... /> (even though this isn't using the custom form builder)
<% end %>

Sad. Quite disappointing. I had to try something else.

7. Try another solution using fiber local variables

Looking through the source code, I couldn't figure out a way to do what I wanted without monkey patching. So I figured that the only way forward was through monkey patching.

module IgnoreFieldErrorProc
  def error_wrapping(html_tag)
    if Thread.current[:custom_form_builder]
      return html_tag
    end

    super
  end
end

ActionView::Helpers::Tags::Base.class_eval do
  prepend IgnoreFieldErrorProc
end

class CustomFormBuilder < ActionView::Helpers::FormBuilder
  def text_field(*)
    Thread.current[:custom_form_builder] = true
    super
  ensure
    Thread.current[:custom_form_builder] = nil
  end
  # We have to override each form builder method that we care about
end

This worked! Forms using the custom form builder correctly bypassed the error proc, but the default behavior stayed the same.

Here's what I finally ended up with. It has a little bit of metaprogramming, but the general idea is exactly the same.

I've tested this to work on Rails 5.0 through 6.1, but I suspect that the general ideal would work in a wider range of Rails versions.

8. A peek into other form builders

After I finished, I remembered that there were other popular form builders, Formtastic and SimpleForm. I took a look. It looks like both do it similar ways; the library would define a method that temporarily reassigned field_error_proc, called the usual form_for, then reset that proc back to what it used to be.

module CustomFormFor
  def custom_form_for(*args, &block)
    original_field_error_proc = ::ActionView::Base.field_error_proc
    ::ActionView::Base.field_error_proc = -> (html_tag, instance) { html_tag }
    form_for(*args, &block)
  ensure
    ::ActionView::Base.field_error_proc = original_field_error_proc
  end
end

ActiveSupport.on_load(:action_view) do
  include CustomFormFor
end

I do wonder if that's thread safe. I assume it's fine, since the aforementioned libraries are quite popular. I have to admit, this is a simpler solution than what I came up with and would require less maintenance.

Posted on 2020-12-06 10:41 PM
Contact
hello(at)zachahn(dot)com
© Copyright 2008–2021 Zach Ahn