Rails 6 introduces a new way of autoloading, integrating the gem Zeitwerk. Rails 7 drops support for classic autoloading, so it’s necessary to switch to Zeitwerk if you ever want to upgrade to Rails 7.
For those not familiar: autoloading in Rails provides access to all your classes and modules automatically, without having to use require
. Making the switch to Zeitwerk-based autoloading can be tricky, because it changes how your application looks up source code files. It’s easy to run into problems if you’re not aware of Zeitwerk’s conventions, and how it differs from the classic autoloader.
module Admin
class ProductsController < ApplicationController
def new
@product = Product.new
end
end
end
In this example; we’re in the Admin::ProductsController
namespace and we want to create a new Product
. To do so, we want to find the Product
constant and we start looking for it in the namespace we are in. Product
constant, does it belong to Admin::ProductsController
? No, it’s not found there, so we check the nesting upwards. Does Admin
have a Product
constant? It does not and now we’ve exhausted the nesting chain.
The search continues up the ancestor chain, where the superclass of Admin::ProductsController
is ApplicationController
. Does ApplicationController
have a Product
constant? It does not so the search continues upwards the ancestor chain, till we eventually get to Object
. Top level classes are all objects so Ruby will be able to find the constant Product
now.
Rails’ classic autoloader works by incorporating file name conventions, autoload paths, Module#const_missing
. The const_missing
hook is called whenever a constant we’re trying to use is missing. From the Product
example, the name Product
would get passed to const_missing
. With that name, the const_missing hook starts looking for the lowercase name in the autoload paths. Once it finds the file, it loads and everything works.
To understand how Zeitwerk works; we need to first understand the Ruby autoload
method.
autoload :Product, 'product'
In the example above, autoload
will load the file product
to load the :Product
constant. When Ruby is trying to resolve a constant (checking modules and classes), if it does not find the constant, but there is an autoload
defined for that constant, it executes the autoload. This happens at the right place at the right time, instead of waiting for the const_missing
hook to be called like in the classic autoloader.
Zeitwerk utilizes this autoload
method while sticking to the file name conventions. Instead of reacting to const_missing
, Zeitwerk scans the project tree before the execution of the application. If there is a product.rb
in the file system, it defines an autoloader for that particular file: autoload :Product, 'product'
.
Rails 6 or higher is required to be able to use Zeitwerk. All you have to do is tell Rails not to use the classic autoloader anymore.
# config/application.rb
config.load_defaults 6.0
config.autoloader = :classic # DELETE THIS LINE
The first step to ensuring Zeitwerk works properly with your application is running the zeitwerk:check
command. What this does is to eager load the application and raise an error if there is a missing constant in autoload paths.
"expected file app/models/product.rb to define constant Product"
The raised error tells us that it can’t find the constant Product
where it should be, product.rb
. Making sure all your modules and classes adhere to the Rails file name conventions should handle these errors. Repeat this command till you get all clear, but that doesn’t mean that you still won’t have runtime errors.
One trick I found very helpful in debugging runtime issues that pop up after switching to Zeitwerk is checking to see what constants are actually being autoloaded. I did this by first putting Rails.autoloader.log!
in config/application.rb
. Then I ran a rails server and saved the log into a file and committed it. I went back to a commit where I wasn’t having issues (pre-Zeitwerk) and ran the Rails.autoloader.log!
command again and copy-pasted that on the file where I had committed the initial logs. The diff that comes up once you save the file will show what constants are not being autoloaded anymore after switching to Zeitwerk.
Another thing I found was calling the ancestors
method on a constant. Let’s say we have a ProductDecorator
, but we also use an extension called Snowball that is also decorating the same Product
class in Snowball::ProductDecorator
. Calling MyApp::Product.ancestors
in a Rails console would be very beneficial at this point:
[1] pry(main)> MyApp::Product.ancestors
=> [Snowball::ProductDecorator,
MyApp::Product,
...
Kernel,
BasicObject]
Here I see that the ProductDecorator
in my application isn’t being loaded for some reason and I can start to investigate why Zeitwerk is having problems with that decorator file.
[1] pry(main)> MyApp::Product.ancestors
=> [Snowball::ProductDecorator,
MyApp::ProductDecorator,
MyApp::Product,
...
Kernel,
BasicObject]
This time, the decorator from my application is being loaded, but not in the order that I want. The extension’s decorator is higher on the ancestral chain than my application’s decorator, which is telling me something might be double loading and causing problems.
In conclusion, switching from the classic autoloader to Zeitwerk can be a complex process due to their fundamental differences. It’s crucial to understand how constants are resolved in Ruby, how the classic autoloader works, and how Zeitwerk works for a successful migration. I hope the debugging methods I’ve shared here helps your migration go smooth!