3 minute read

A while ago I started ~complaining~ thinking about how the default erb views in Ruby on Rails could be improved.

Some context: at Cobot we run a Rails app that was started in 2009 and has about 1000 .html.erb files, including partials and component views.

I had identified the following issues:

  • The syntax is very complex due to the fact that any Ruby construct can be embedded.
    • This makes it harder to read/write views.
    • and also for tools to format them, or do auto-completion, syntax highlighting etc.
  • Correctness of HTML (think missing closing tags) cannot be verified by tools.
  • I rarely write tests for views (mostly only via feature tests) so confidence in their correctness (also in conjunction with their associated controller) is not very high - I’m also not really planning to get into it.

I like that it’s still HTML (and our designers agree), but I find the Ruby in there problematic. In our frontend Ember JS apps we use Glimmer, which uses the same syntax as Handlebars:

{{#if myCondition}}
  <p>{{firstname}} {{loud lastname}}</p>
{{/if}}

I like the concept of having a limited syntax for control flow and callig into code on top of HTML.

Not too long ago we added glint to our Ember stack, which together with TypeScript gives us static type checking in our Glimmer templates. Very nice.

And that gave me the idea that lead to this post: we already use Sorbet for type checking our Ruby code. If we could have type checking for our Rails views as well, that would give us much more confidence - without having to write tests for them.

So far, I haven’t figured out a way to do this with controllers since the interface between them and views is just too fuzzy. But for view components it was actually easy.

The following code makes a copy of each component’s Ruby class, compiles the associated .erb file to Ruby and embeds it into the class as a method:

require 'pathname'

class ComponentViewCompiler
  INITIALIZE_METHOD_REGEX = /def initialize.*?end/m
  COMPONENTS_PATH = Pathname.new('app/components')

  def initialize(target_dir)
    @target_dir = target_dir
    FileUtils.mkdir_p(@target_dir)
  end

  def compile_components
    COMPONENTS_PATH
      .glob('**/*.html.erb')
      .each do |view_file|
        class_file = view_file.sub_ext('').sub_ext('.rb')
        compile_component(view_file, class_file)
      end
  end

  private

  def compile_component(view_file, class_file)
    view = File.read(view_file)
    compiled_view = compile_view(view)
    code = File.read(class_file)
    call_method =
      "\n\nsig { void }\ndef call\noutput_buffer = ActionView::OutputBuffer.new\n#{
        compiled_view.gsub('@output_buffer', 'output_buffer')
      }\nend\n"
    index = code.match(INITIALIZE_METHOD_REGEX).end(0)
    code.insert(index, call_method) # yes this is ugly. will break with no or multiple initialize methods, e.g. nested classes. but it works.
    target_file =
      @target_dir.join(class_file.relative_path_from(COMPONENTS_PATH))
    FileUtils.mkdir_p(File.dirname(target_file))
    File.write(target_file, code)
  end

  def compile_view(view)
    handler = ActionView::Template.handler_for_extension('erb')
    handler.call(OpenStruct.new(type: 'erb'), view)
  end
end

Given a simple component:

my_component.rb:

# typed: true

class MyComponent < ApplicationComponent
  extend T::Sig # add sorbet

  sig { params(name: String).void } # sorbet method signature
  def initialize(name)
    super
    @name = name
  end

  sig { returns(String) } # sorbet signature
  attr_reader :name
end

my_component.html.erb:

Hello <%= namne %>

This will be compiled to:

compiled_components/my_component.rb:

# typed: true

class MyComponent < ApplicationComponent
  extend T::Sig # add sorbet

  sig { params(name: String).void } # sorbet method signature
  def initialize(name)
    super
    @name = name
  end

  sig { void }
  def call
    output_buffer = ActionView::OutputBuffer.new
    output_buffer.safe_append='hello'.freeze; output_buffer.append=( namne )
    output_buffer
  end

  sig { returns(String) } # sorbet signature
  attr_reader :name
end

The resulting file can be typechecked by sorbet (or steep if you prefer):

$ srb tc

compiled_components/my_component.rb:15: Method namne does not exist on MyComponent https://srb.help/7003
    15 |    output_buffer.safe_append='hello'.freeze; output_buffer.append=( namne )
                                                                             ^^^^^
  Did you mean name? Use -a to autocorrect
    compiled_components/my_component.rb:15: Replace with name
    15 |    output_buffer.safe_append='hello'.freeze; output_buffer.append=( namne )
                                                                             ^^^^^
    compiled_components/my_component.rb:20: Defined here
    20 |  attr_reader :name
          ^^^^^^^^^^^^^^^^^

And as you can see I made a typo in the template.

The downside here is that any type errors point to the compiled class, so nether the filename nor the line number in the error message are correct.

But it’s a start. And to get around the part where this is not working for controllers, we just convert any complex and/or very important view into a component.

As a bonus, I added the component compiler to Procfile.dev, so now as long as bin/rails is running, the compiled components are kept up-to-date automatically.

Procfile.dev:

compiled_component_views: npx -y watch "rails sorbet:compile_component_views" app/components

sorbet.rake:

# typed: false

namespace :sorbet do
  desc 'Generate Ruby code from component views for Sorbet. This allows to type-check component views.'
  task :compile_component_views do
    require 'pathname'
    require 'fileutils'
    require_relative '../component_view_compiler'

    puts 'Compiling component views'
    dir = Pathname.new('compiled_components')
    FileUtils.rm_rf(dir)
    compiler = ComponentViewCompiler.new(dir)
    compiler.compile_components
  end
end

The same Rake task also runs on CI.

Happy static type checking!

Comments