Ruby on Rails – How to Set View Context in Rails for Enhanced MVC Performance

model-view-controllerrubyruby-on-railsrubygems

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.

Best Answer

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"]
]