Skip to content

Instantly share code, notes, and snippets.

@shime
Last active March 13, 2020 09:39
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save shime/6696842 to your computer and use it in GitHub Desktop.
Save shime/6696842 to your computer and use it in GitHub Desktop.
bake your own code reloader for rails

Let's build our own code reloader for Rails, shall we?

Require dependency problems

Run this inside rails/railties:

$ grep -rn "eager_load_paths" .

You should get the results from Rails::Engine::Configuration and Rails::Engine. As you know, each Rails application is actually a Rails::Engine and Rails::Engine::Configuration is that thing wrapped inside Rails.application.config block.

This is the method responsible for eager loading in Rails::Engine:

def eager_load!
  config.eager_load_paths.each do |load_path|
    matcher = /\A#{Regexp.escape(load_path)}\/(.*)\.rb\Z/
    Dir.glob("#{load_path}/**/*.rb").sort.each do |file|
      require_dependency file.sub(matcher, '\1')
    end
  end
end

As you can see, it uses require_dependency and you can read more about its magic in this great answer. Long story short, Ruby instance doesn't allow you to "unrequire" a file, it does its requiring only one time. That's where require_dependency comes in.

Try using it and it will still not work. That's because require_dependency is depending on the context it's called from. Let me explain.

If you have a controller like this

require_dependency "monkey"

class MonkeyController < ApplicationController
  def index
    render :text => Monkey.new.greet
  end
end

and the matching lib file looks like this

class Monkey
  def greet
    "ZOMG, I'm a monkey!"
  end
end

then Monkey will be reloaded only when the controller is reloaded. Controller is reloaded only when you change it, so this is probably not what you want.

Reloading on every request

You want your monkey to be reloaded on each request and things get a little bit complicated because of that.

Let's see where this happens... Take a look at Rails::Application::Finisher. This is the thing responsible for finishing the Rails initialization process.

  # Set clearing dependencies after the finisher hook to ensure paths
  # added in the hook are taken into account.
  initializer :set_clear_dependencies_hook, group: :all do
    callback = lambda do
      ActiveSupport::DescendantsTracker.clear
      ActiveSupport::Dependencies.clear
    end

    if config.reload_classes_only_on_change
      reloader = config.file_watcher.new(*watchable_args, &callback)
      self.reloaders << reloader

      # Prepend this callback to have autoloaded constants cleared before
      # any other possible reloading, in case they need to autoload fresh
      # constants.
      ActionDispatch::Reloader.to_prepare(prepend: true) do
        # In addition to changes detected by the file watcher, if routes
        # or i18n have been updated we also need to clear constants,
        # that's why we run #execute rather than #execute_if_updated, this
        # callback has to clear autoloaded constants after any update.
        reloader.execute
      end
    else
      ActionDispatch::Reloader.to_cleanup(&callback)
    end
  end

It looks like ActionDispatch::Reloader and ActiveSupport::FileUpdateChecker are doing all that's necessary for this. Reloader is just a Rack middleware (hit rake middleware to convince yourself) which calls FileUpdateChecker on every request.

Now that we know all of this, we can build our own reloader that's thousand times simpler than this and you should probably burn it after:

class StupidReloader
  def initialize(app)
    @app = app
  end

  def call(env)
    do_crazy_stuff!

    @app.call env
  end

  private

    def do_crazy_stuff!
      ActiveSupport::DescendantsTracker.clear
      ActiveSupport::Dependencies.clear

      require_dependency "#{File.join(Rails.root, "lib", "monkey")}"
    end
end

Replace the default Rails' reloader:

# somewhere inside config/environments/development.rb
config.middleware.swap ActionDispatch::Reloader,
  StupidReloader # we like to live dangerously 

Monkey should now get reloaded on every request.

@pirj
Copy link

pirj commented Mar 13, 2020

ActiveSupport::Dependencies.clear

Don't do that. This is causing class constants to be removed and new ones with the same name defined.
This might become very problematic and hard to debug in some cases.

> class Abracadabra; end
> Abracadabra    
=> Abracadabra
> self.class.send(:remove_const, :Abracadabra) # This is basically what `ActiveSupport::Dependencies.clear
` does
=> Abracadabra
> ObjectSpace.each_object.select { |o| o.is_a? Class }.select { |c| c.name == 'Abracadabra' }
=> [Abracadabra]
> Abracadabra     
NameError: uninitialized constant Abracadabra
> class Abracadabra; end
> ObjectSpace.each_object.select { |o| o.is_a? Class }.select { |c| c.name == 'Abracadabra' }
=> [Abracadabra, Abracadabra]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment