Tutorial

Auto re-load Rails initializers (and other files) in development

April 11, 2022 07:44

I fixed a shitty situation I created for myself two years back a few days back. What a fantastic feeling that was. I'm still in awe when everything just works.

When I first started to work on Avo, I didn't know much about Rails' inner workings and file-reloading. So as some might not know, Avo helps developers ship apps quickly. Using a few declarative configuration files that the developer is generating in their own app, it does that. Those files are inherited from a few base files UserResource from BaseResource, ToggleAdminAction from BaseAction, TextField from BaseField, and so on.

The issue arose in Avo's development process. When you were making an update to the BaseResource, the changes were not showing up in the Dummy app. The server was not picking them up and wasn't reloading those inherited classes. You had to restart the server for those changes to come into effect.

Over the years, I got pretty clever on how to work around this because there are more pressing issues that I can fix with my time. But I was still thinking about it and coming back to it to try to fix it from time to time.

How did others fix it?

I checked all the guides on autoreloading, literally read the whole docs page and some of the source code of zeitwerk, checked out a lot of purpose made tutorials about code reloading in development and nothing really worked as advertised. I couldn't wrap my head around why it was not working.

Until Friday; I asked myself, "what have others done to fix this?". Then, I remembered that DHH posted an early demo video of importmaps where he stated something along the lines of "we need to restart the server for the importmaps file to register changes. This is something that we'll fix before release...". So, it's released now, so they've fixed it; time to see what they did.

They built this Reloader class that holds all the configuration and the reload! method.

# lib/importmap/reloader.rb
class Importmap::Reloader
  delegate :execute_if_updated, :execute, :updated?, to: :updater

  def reload!
    import_map_paths.each { |path| Rails.application.importmap.draw(path) }
  end

  private
    def updater
      @updater ||= config.file_watcher.new(import_map_paths) { reload! }
    end

    def import_map_paths
      config.importmap.paths
    end

    def config
      Rails.application.config
    end
end

# engine
initializer "importmap.reloader" do |app|
  Importmap::Reloader.new.tap do |reloader|
    reloader.execute
    app.reloaders << reloader
    app.reloader.to_run { reloader.execute }
  end
end

So what are they doing here? First, the reload! method tells Rails what to do when it needs to run. In their case, it takes the importmap files config.importmap.paths and runs Rails.application.importmap.draw for each one. So basically, re-registering the importmaps. Next, they create a file watcher that watches over the files they need.

In the initializer, they add the reloader to the app's reloaders and instruct the app what to do when changes have been detected.

Translate that to watching and reloading the initializer

How does that translate to watching and reloading the initializer and our base classes? We'll tweak a few methods in the reloader.

First, define the paths that we want to watch. For example, we'd like to watch for changes in the initializer (a file) and the whole lib directory. So we're going to remove paths and add two methods, files and directories.

def files
  # we want to watch some files no matter what
  paths = [
    Rails.root.join("config", "initializers", "avo.rb"),
  ]

  paths
end

def directories
  dirs = {}

  # watch the lib directory in Avo development
  if reload_lib?
    dirs[Avo::Engine.root.join("lib", "avo").to_s] = ["rb"]
  end

  dirs
end

You might have noticed the reload_lib? method. That's there to instruct Rails to reload the lib directory only when we tell it to or doing development on Avo, not the parent app.

def reload_lib?
  Avo::IN_DEVELOPMENT || ENV['AVO_RELOAD_LIB_DIR']
end

Next, we will change what the reload! method does. We're going to reload all the files in files and all the files in the directories we specified.

def reload!
  # reload all files declared in paths
  files.each { |file| load file }

  # reload all files declared in each directory
  directories.keys.each do |dir|
    Dir.glob("#{dir}/**/*.rb".to_s).each { |file| load file }
  end
end

Put it all together

class Avo::Reloader
  delegate :execute_if_updated, :execute, :updated?, to: :updater

  def reload!
    # reload all files declared in paths
    files.each { |file| load file }

    # reload all files declared in each directory
    directories.keys.each do |dir|
      Dir.glob("#{dir}/**/*.rb".to_s).each { |file| load file }
    end
  end

  private
    def updater
      @updater ||= config.file_watcher.new(files, directories) { reload! }
    end

    def files
      # we want to watch some files no matter what
      paths = [
        Rails.root.join("config", "initializers", "avo.rb"),
      ]

      # we want to watch some files only in Avo development
      if reload_lib?
        paths += []
      end

      paths
    end

    def directories
      dirs = {}

      # watch the lib directory in Avo development
      if reload_lib?
        dirs[Avo::Engine.root.join("lib", "avo").to_s] = ["rb"]
      end

      dirs
    end

    def config
      Rails.application.config
    end

    def reload_lib?
      Avo::IN_DEVELOPMENT || ENV['AVO_RELOAD_LIB_DIR']
    end
end

The Rails app now knows what to do when something has changed in the parent app. It also knows to watch over the paths that we specified. So now, when you make a change in the initializer or any file in the lib directory, Rails will pick up those changes without you needing to reload the whole server.

I hope this helps you create better apps faster.

PS: If you want to create apps even faster, try out Avo. We sweat on the little details, so you don't have to.

https://avo.cool