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.