Search code examples
ruby-on-railsrubymodel-view-controllerrubygems

How to set view context in Rails?


I’m building a gem that intercepts Rails’ default render behavior. Instead of rendering a view, it looks for a helper method that corresponds to the current controller’s action name; it then uses the helper method’s return value to construct an HTML response.

For instance, when the show action of the ProductsController is called, the gem will call the show method on the ProductsHelper. It then turns that method’s return value into HTML and renders it.

Here’s the relevant code from my gem. It runs inside a controller context:

def render_helper_method action_name, options = {}
  helper_module = "#{self.class.name.gsub('Controller', '')}Helper".constantize

  if helper_module.instance_methods(false).include?(action_name.to_sym)
    content = helper_module.instance_method(action_name).bind(view_context).call
    original_render({ html: Hiccdown::to_html(content).html_safe, layout: true }.merge(options))
  else
    original_render({ action: action_name }.merge(options))
  end
end

The problem occurs in the first line of the conditional.

As you can see, I bind the helper method to the view_context. Again, this code runs in a controller, so this is the controller’s view_context. The view’s view_context is different, however (I have compared their object_ids). I understand this is an expected difference in any Rails app – apparently, the controller and view are not supposed to share the same view_context. (I have verified that my gem does not introduce this difference.)

While controllers and views are supposed to have different view_contexts, helpers and views are supposed to share the same view_context. They require the same context so that they have the shared state needed to support functionality such as content_for.

Therefore, I think the helper method should be bound to the view_context that is eventually used to render the page, application layout and all. (I suppose it’s a bit of a chicken-and-egg problem since I need a reference to that context before I call render!)

I’m looking for a way to bind the helper method to the correct view_context. I’m also open to achieving the same functionality in a different way that doesn’t cause this problem in the first place.


Solution

  • I see two solutions, make a component or make a template handler.

    Component

    This component will receive view_context instance when rendering:

    class Hiccdown::Component
      def initialize(helper_module, action_name)
        @helper_module = helper_module
        @action_name = action_name
      end
    
      def render_in(view_context)
        Hiccdown.to_html(
          @helper_module.instance_method(@action_name).bind_call(view_context)
        )
      end
    
      def format
        :html
      end
    end
    

    Update your render method:

    def render_helper_method action_name, options = {}
      helper_module = "#{self.class.name.gsub('Controller', '')}Helper".constantize
    
      if helper_module.method_defined? action_name
        original_render(Hiccdown::Component.new(helper_module, action_name), options)
      else
        original_render({ action: action_name }.merge(options))
      end
    end
    
    # app/helpers/home_helper.rb
    
    module HomeHelper
      def show
        # this should work now
        content_for :header do
          "header"
        end
        [:p, "content"]
      end
    end
    

    https://guides.rubyonrails.org/layouts_and_rendering.html#rendering-objects


    Template handler

    Since you're making a template language, this would be nice to have:

    # config/initializers/hiccdown.rb
    
    # render hiccdown: [:p, "content"]
    ActiveSupport.on_load(:action_controller) do
      ActionController::Renderers.add :hiccdown do |source, options|
        render html: Hiccdown.to_html(source).html_safe, layout: options[:layout]
      end
    end
    
    # render .hiccdown templates
    class HiccdownHandler
      def self.call(template, source)
        %{ Hiccdown.to_html(#{source}.compact) }
      end
    end
    ActiveSupport.on_load(:action_view) do
      ActionView::Template.register_template_handler :hiccdown, HiccdownHandler
    end
    

    Example:

    class HomeController < ApplicationController
      def show
        render hiccdown: [:h1, "title"]
      end
    end
    

    or create a template:

    # app/views/home/show.html.hiccdown
    
    [
      content_for(:title, "foo"),
      [:h1, "title"]
    ]