diff options
-rw-r--r-- | Gemfile | 4 | ||||
-rw-r--r-- | Gemfile.lock | 18 | ||||
-rw-r--r-- | actionmailer/lib/action_mailer/railtie.rb | 16 | ||||
-rw-r--r-- | activesupport/lib/active_support/dependencies/zeitwerk_integration.rb | 71 | ||||
-rw-r--r-- | railties/lib/rails.rb | 20 | ||||
-rw-r--r-- | railties/lib/rails/application/configuration.rb | 13 | ||||
-rw-r--r-- | railties/lib/rails/application/finisher.rb | 8 | ||||
-rw-r--r-- | railties/lib/rails/engine.rb | 26 | ||||
-rw-r--r-- | railties/lib/rails/generators/rails/app/templates/Gemfile.tt | 6 | ||||
-rw-r--r-- | railties/test/application/configuration_test.rb | 21 | ||||
-rw-r--r-- | railties/test/application/mailer_previews_test.rb | 3 | ||||
-rw-r--r-- | railties/test/application/multiple_applications_test.rb | 24 | ||||
-rw-r--r-- | railties/test/application/rake_test.rb | 4 | ||||
-rw-r--r-- | railties/test/application/zeitwerk_integration_test.rb | 164 | ||||
-rw-r--r-- | railties/test/generators/app_generator_test.rb | 9 | ||||
-rw-r--r-- | railties/test/isolation/abstract_unit.rb | 10 | ||||
-rw-r--r-- | railties/test/railties/engine_test.rb | 32 |
17 files changed, 383 insertions, 66 deletions
@@ -44,7 +44,9 @@ gem "libxml-ruby", platforms: :ruby gem "connection_pool", require: false # for railties app_generator_test -gem "bootsnap", ">= 1.1.0", require: false +gem "bootsnap", ">= 1.4.0", require: false + +gem "zeitwerk", ">= 1.0.0" if RUBY_ENGINE == "ruby" # Active Job group :job do diff --git a/Gemfile.lock b/Gemfile.lock index b05ae7915e..fa8094bf23 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -166,9 +166,9 @@ GEM childprocess faraday selenium-webdriver - bootsnap (1.3.2) + bootsnap (1.4.0) msgpack (~> 1.0) - bootsnap (1.3.2-java) + bootsnap (1.4.0-java) msgpack (~> 1.0) builder (3.2.3) bunny (2.13.0) @@ -324,10 +324,10 @@ GEM minitest-server (1.0.5) minitest (~> 5.0) mono_logger (1.1.0) - msgpack (1.2.4) - msgpack (1.2.4-java) - msgpack (1.2.4-x64-mingw32) - msgpack (1.2.4-x86-mingw32) + msgpack (1.2.6) + msgpack (1.2.6-java) + msgpack (1.2.6-x64-mingw32) + msgpack (1.2.6-x86-mingw32) multi_json (1.13.1) multipart-post (2.0.0) mustache (1.1.0) @@ -516,6 +516,7 @@ GEM websocket-extensions (0.1.3) xpath (3.2.0) nokogiri (~> 1.8) + zeitwerk (1.0.0) PLATFORMS java @@ -535,7 +536,7 @@ DEPENDENCIES benchmark-ips blade blade-sauce_labs_plugin - bootsnap (>= 1.1.0) + bootsnap (>= 1.4.0) byebug capybara (>= 2.15) chromedriver-helper @@ -588,6 +589,7 @@ DEPENDENCIES webmock webpacker (>= 4.0.0.rc.3) websocket-client-simple! + zeitwerk (>= 1.0.0) BUNDLED WITH - 1.17.2 + 1.17.3 diff --git a/actionmailer/lib/action_mailer/railtie.rb b/actionmailer/lib/action_mailer/railtie.rb index 23488db790..893a4a25b1 100644 --- a/actionmailer/lib/action_mailer/railtie.rb +++ b/actionmailer/lib/action_mailer/railtie.rb @@ -59,6 +59,14 @@ module ActionMailer end end + initializer "action_mailer.set_autoload_paths" do |app| + options = app.config.action_mailer + + if options.show_previews && options.preview_path + ActiveSupport::Dependencies.autoload_paths << options.preview_path + end + end + initializer "action_mailer.compile_config_methods" do ActiveSupport.on_load(:action_mailer) do config.compile_methods! if config.respond_to?(:compile_methods!) @@ -76,12 +84,8 @@ module ActionMailer if options.show_previews app.routes.prepend do - get "/rails/mailers" => "rails/mailers#index", internal: true - get "/rails/mailers/*path" => "rails/mailers#preview", internal: true - end - - if options.preview_path - ActiveSupport::Dependencies.autoload_paths << options.preview_path + get "/rails/mailers" => "rails/mailers#index", internal: true + get "/rails/mailers/*path" => "rails/mailers#preview", internal: true end end end diff --git a/activesupport/lib/active_support/dependencies/zeitwerk_integration.rb b/activesupport/lib/active_support/dependencies/zeitwerk_integration.rb new file mode 100644 index 0000000000..8b2e1b5391 --- /dev/null +++ b/activesupport/lib/active_support/dependencies/zeitwerk_integration.rb @@ -0,0 +1,71 @@ +module ActiveSupport + module Dependencies + module ZeitwerkIntegration # :nodoc: all + module Decorations + def clear + Dependencies.unload_interlock do + Rails.autoloader.reload + end + end + + def constantize(cpath) + Inflector.constantize(cpath) + end + + def safe_constantize(cpath) + Inflector.safe_constantize(cpath) + end + + def autoloaded_constants + Rails.autoloaders.flat_map do |autoloader| + autoloader.loaded.to_a + end + end + + def autoloaded?(object) + cpath = object.is_a?(Module) ? object.name : object.to_s + Rails.autoloaders.any? { |autoloader| autoloader.loaded?(cpath) } + end + end + + class << self + def take_over + setup_autoloaders + freeze_autoload_paths + decorate_dependencies + end + + private + + def setup_autoloaders + Dependencies.autoload_paths.each do |autoload_path| + if File.directory?(autoload_path) + if autoload_once?(autoload_path) + Rails.once_autoloader.push_dir(autoload_path) + else + Rails.autoloader.push_dir(autoload_path) + end + end + end + + Rails.autoloaders.each(&:setup) + end + + def autoload_once?(autoload_path) + Dependencies.autoload_once_paths.include?(autoload_path) || + Gem.path.any? { |gem_path| autoload_path.to_s.start_with?(gem_path) } + end + + def freeze_autoload_paths + Dependencies.autoload_paths.freeze + Dependencies.autoload_once_paths.freeze + end + + def decorate_dependencies + Dependencies.singleton_class.prepend(Decorations) + Object.class_eval { alias_method :require_dependency, :require } + end + end + end + end +end diff --git a/railties/lib/rails.rb b/railties/lib/rails.rb index 092105d502..bca2cf34e1 100644 --- a/railties/lib/rails.rb +++ b/railties/lib/rails.rb @@ -110,5 +110,25 @@ module Rails def public_path application && Pathname.new(application.paths["public"].first) end + + def autoloader + if configuration.autoloader == :zeitwerk + @autoloader ||= Zeitwerk::Loader.new + end + end + + def once_autoloader + if configuration.autoloader == :zeitwerk + @once_autoloader ||= Zeitwerk::Loader.new + end + end + + def autoloaders + if configuration.autoloader == :zeitwerk + [autoloader, once_autoloader] + else + [] + end + end end end diff --git a/railties/lib/rails/application/configuration.rb b/railties/lib/rails/application/configuration.rb index b7838f7e32..16fbc99e7a 100644 --- a/railties/lib/rails/application/configuration.rb +++ b/railties/lib/rails/application/configuration.rb @@ -20,7 +20,7 @@ module Rails :read_encrypted_secrets, :log_level, :content_security_policy_report_only, :content_security_policy_nonce_generator, :require_master_key, :credentials - attr_reader :encoding, :api_only, :loaded_config_version + attr_reader :encoding, :api_only, :loaded_config_version, :autoloader def initialize(*) super @@ -64,6 +64,7 @@ module Rails @credentials = ActiveSupport::OrderedOptions.new @credentials.content_path = default_credentials_content_path @credentials.key_path = default_credentials_key_path + @autoloader = :classic end def load_defaults(target_version) @@ -117,6 +118,8 @@ module Rails when "6.0" load_defaults "5.2" + self.autoloader = :zeitwerk if RUBY_ENGINE == "ruby" + if respond_to?(:action_view) action_view.default_enforce_utf8 = false end @@ -267,6 +270,14 @@ module Rails end end + def autoloader=(autoloader) + if %i(classic zeitwerk).include?(autoloader) + @autoloader = autoloader + else + raise ArgumentError, "config.autoloader may be :classic or :zeitwerk, got #{autoloader.inspect} instead" + end + end + class Custom #:nodoc: def initialize @configurations = Hash.new diff --git a/railties/lib/rails/application/finisher.rb b/railties/lib/rails/application/finisher.rb index 04aaf6dd9a..39e8ef6631 100644 --- a/railties/lib/rails/application/finisher.rb +++ b/railties/lib/rails/application/finisher.rb @@ -21,6 +21,13 @@ module Rails end end + initializer :let_zeitwerk_take_over do + if config.autoloader == :zeitwerk + require "active_support/dependencies/zeitwerk_integration" + ActiveSupport::Dependencies::ZeitwerkIntegration.take_over + end + end + initializer :add_builtin_route do |app| if Rails.env.development? app.routes.prepend do @@ -66,6 +73,7 @@ module Rails initializer :eager_load! do if config.eager_load ActiveSupport.run_load_hooks(:before_eager_load, self) + Zeitwerk::Loader.eager_load_all if defined?(Zeitwerk) config.eager_load_namespaces.each(&:eager_load!) end end diff --git a/railties/lib/rails/engine.rb b/railties/lib/rails/engine.rb index d6c329b581..2485158a7b 100644 --- a/railties/lib/rails/engine.rb +++ b/railties/lib/rails/engine.rb @@ -472,12 +472,10 @@ module Rails # Eager load the application by loading all ruby # files inside eager_load paths. def eager_load! - config.eager_load_paths.each do |load_path| - # Starts after load_path plus a slash, ends before ".rb". - relname_range = (load_path.to_s.length + 1)...-3 - Dir.glob("#{load_path}/**/*.rb").sort.each do |file| - require_dependency file[relname_range] - end + if Rails.autoloader + eager_load_with_zeitwerk! + else + eager_load_with_dependencies! end end @@ -653,6 +651,22 @@ module Rails private + def eager_load_with_zeitwerk! + (config.eager_load_paths - Zeitwerk::Loader.all_dirs).each do |path| + Dir.glob("#{path}/**/*.rb").sort.each { |file| require file } + end + end + + def eager_load_with_dependencies! + config.eager_load_paths.each do |load_path| + # Starts after load_path plus a slash, ends before ".rb". + relname_range = (load_path.to_s.length + 1)...-3 + Dir.glob("#{load_path}/**/*.rb").sort.each do |file| + require_dependency file[relname_range] + end + end + end + def load_config_initializer(initializer) # :doc: ActiveSupport::Notifications.instrument("load_config_initializer.railties", initializer: initializer) do load(initializer) diff --git a/railties/lib/rails/generators/rails/app/templates/Gemfile.tt b/railties/lib/rails/generators/rails/app/templates/Gemfile.tt index d39b5d311f..a1f1224a45 100644 --- a/railties/lib/rails/generators/rails/app/templates/Gemfile.tt +++ b/railties/lib/rails/generators/rails/app/templates/Gemfile.tt @@ -28,7 +28,7 @@ ruby <%= "'#{RUBY_VERSION}'" -%> <% if depend_on_bootsnap? -%> # Reduces boot times through caching; required in config/boot.rb -gem 'bootsnap', '>= 1.1.0', require: false +gem 'bootsnap', '>= 1.4.0', require: false <%- end -%> <%- if options.api? -%> @@ -36,7 +36,9 @@ gem 'bootsnap', '>= 1.1.0', require: false # gem 'rack-cors' <%- end -%> -<% if RUBY_ENGINE == 'ruby' -%> +<% if RUBY_ENGINE == "ruby" -%> +gem "zeitwerk", ">= 1.0.0" + group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] diff --git a/railties/test/application/configuration_test.rb b/railties/test/application/configuration_test.rb index 9ea4d6dc5f..960f708bdf 100644 --- a/railties/test/application/configuration_test.rb +++ b/railties/test/application/configuration_test.rb @@ -1157,6 +1157,27 @@ module ApplicationTests end end + test "autoloader & autoloader=" do + app "development" + + config = Rails.application.config + assert_instance_of Zeitwerk::Loader, Rails.autoloader + assert_instance_of Zeitwerk::Loader, Rails.once_autoloader + assert_equal [Rails.autoloader, Rails.once_autoloader], Rails.autoloaders + + config.autoloader = :classic + assert_nil Rails.autoloader + assert_nil Rails.once_autoloader + assert_empty Rails.autoloaders + + config.autoloader = :zeitwerk + assert_instance_of Zeitwerk::Loader, Rails.autoloader + assert_instance_of Zeitwerk::Loader, Rails.once_autoloader + assert_equal [Rails.autoloader, Rails.once_autoloader], Rails.autoloaders + + assert_raises(ArgumentError) { config.autoloader = :unknown } + end + test "config.action_view.cache_template_loading with cache_classes default" do add_to_config "config.cache_classes = true" diff --git a/railties/test/application/mailer_previews_test.rb b/railties/test/application/mailer_previews_test.rb index ba186bda44..fb84276b8a 100644 --- a/railties/test/application/mailer_previews_test.rb +++ b/railties/test/application/mailer_previews_test.rb @@ -85,6 +85,7 @@ module ApplicationTests end test "mailer previews are loaded from a custom preview_path" do + app_dir "lib/mailer_previews" add_to_config "config.action_mailer.preview_path = '#{app_path}/lib/mailer_previews'" mailer "notifier", <<-RUBY @@ -254,6 +255,7 @@ module ApplicationTests end test "mailer previews are reloaded from a custom preview_path" do + app_dir "lib/mailer_previews" add_to_config "config.action_mailer.preview_path = '#{app_path}/lib/mailer_previews'" app("development") @@ -818,6 +820,7 @@ module ApplicationTests def build_app super app_file "config/routes.rb", "Rails.application.routes.draw do; end" + app_dir "test/mailers/previews" end def mailer(name, contents) diff --git a/railties/test/application/multiple_applications_test.rb b/railties/test/application/multiple_applications_test.rb index 432344bccc..f0f1112f6b 100644 --- a/railties/test/application/multiple_applications_test.rb +++ b/railties/test/application/multiple_applications_test.rb @@ -100,30 +100,6 @@ module ApplicationTests assert_nothing_raised { AppTemplate::Application.new } end - def test_initializers_run_on_different_applications_go_to_the_same_class - application1 = AppTemplate::Application.new - run_count = 0 - - AppTemplate::Application.initializer :init0 do - run_count += 1 - end - - application1.initializer :init1 do - run_count += 1 - end - - AppTemplate::Application.new.initializer :init2 do - run_count += 1 - end - - assert_equal 0, run_count, "Without loading the initializers, the count should be 0" - - # Set config.eager_load to false so that an eager_load warning doesn't pop up - AppTemplate::Application.create { config.eager_load = false }.initialize! - - assert_equal 3, run_count, "There should have been three initializers that incremented the count" - end - def test_consoles_run_on_different_applications_go_to_the_same_class run_count = 0 AppTemplate::Application.console { run_count += 1 } diff --git a/railties/test/application/rake_test.rb b/railties/test/application/rake_test.rb index 44e3b0f66b..fe56e3d076 100644 --- a/railties/test/application/rake_test.rb +++ b/railties/test/application/rake_test.rb @@ -145,8 +145,8 @@ module ApplicationTests # loading a specific fixture rails "db:fixtures:load", "FIXTURES=products" - assert_equal 2, ::AppTemplate::Application::Product.count - assert_equal 0, ::AppTemplate::Application::User.count + assert_equal 2, Product.count + assert_equal 0, User.count end def test_loading_only_yml_fixtures diff --git a/railties/test/application/zeitwerk_integration_test.rb b/railties/test/application/zeitwerk_integration_test.rb new file mode 100644 index 0000000000..d44b7f7e4d --- /dev/null +++ b/railties/test/application/zeitwerk_integration_test.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require "isolation/abstract_unit" +require "active_support/dependencies/zeitwerk_integration" + +class ZeitwerkIntegrationTest < ActiveSupport::TestCase + include ActiveSupport::Testing::Isolation + + def setup + build_app + end + + def boot(env = "development") + app(env) + end + + def teardown + teardown_app + end + + def deps + ActiveSupport::Dependencies + end + + def decorated? + deps.singleton_class < deps::ZeitwerkIntegration::Decorations + end + + test "ActiveSupport::Dependencies is decorated by default" do + boot + + assert decorated? + assert_instance_of Zeitwerk::Loader, Rails.autoloader + assert_instance_of Zeitwerk::Loader, Rails.once_autoloader + assert_equal [Rails.autoloader, Rails.once_autoloader], Rails.autoloaders + end + + test "ActiveSupport::Dependencies is not decorated in classic mode" do + add_to_config 'config.autoloader = :classic' + boot + + assert_not decorated? + assert_nil Rails.autoloader + assert_nil Rails.once_autoloader + assert_empty Rails.autoloaders + end + + test "constantize returns the value stored in the constant" do + app_file "app/models/admin/user.rb", "class Admin::User; end" + boot + + assert_same Admin::User, deps.constantize("Admin::User") + end + + test "constantize raises if the constant is unknown" do + boot + + assert_raises(NameError) { deps.constantize("Admin") } + end + + test "safe_constantize returns the value stored in the constant" do + app_file "app/models/admin/user.rb", "class Admin::User; end" + boot + + assert_same Admin::User, deps.safe_constantize("Admin::User") + end + + test "safe_constantize returns nil for unknown constants" do + boot + + assert_nil deps.safe_constantize("Admin") + end + + test "autoloaded_constants returns autoloaded constant paths" do + app_file "app/models/admin/user.rb", "class Admin::User; end" + app_file "app/models/post.rb", "class Post; end" + boot + + assert Admin::User + assert_equal ["Admin", "Admin::User"], deps.autoloaded_constants + end + + test "autoloaded? says if a constant has been autoloaded" do + app_file "app/models/user.rb", "class User; end" + app_file "app/models/post.rb", "class Post; end" + boot + + assert Post + assert deps.autoloaded?("Post") + assert deps.autoloaded?(Post) + assert_not deps.autoloaded?("User") + end + + test "eager loading loads the application code" do + $zeitwerk_integration_test_user = false + $zeitwerk_integration_test_post = false + + app_file "app/models/user.rb", "class User; end; $zeitwerk_integration_test_user = true" + app_file "app/models/post.rb", "class Post; end; $zeitwerk_integration_test_post = true" + boot("production") + + assert $zeitwerk_integration_test_user + assert $zeitwerk_integration_test_post + end + + test "eager loading loads anything managed by Zeitwerk" do + $zeitwerk_integration_test_user = false + app_file "app/models/user.rb", "class User; end; $zeitwerk_integration_test_user = true" + + $zeitwerk_integration_test_extras = false + app_dir "extras" + app_file "extras/webhook_hacks.rb", "WebhookHacks = 1; $zeitwerk_integration_test_extras = true" + + require "zeitwerk" + autoloader = Zeitwerk::Loader.new + autoloader.push_dir("#{app_path}/extras") + autoloader.setup + + boot("production") + + assert $zeitwerk_integration_test_user + assert $zeitwerk_integration_test_extras + end + + test "autoload paths that are below Gem.path go to the once autoloader" do + app_dir "extras" + add_to_config 'config.autoload_paths << "#{Rails.root}/extras"' + + # Mocks Gem.path to include the extras directory. + Gem.singleton_class.prepend( + Module.new do + def path + super + ["#{Rails.root}/extras"] + end + end + ) + boot + + refute_includes Rails.autoloader.dirs, "#{app_path}/extras" + assert_includes Rails.once_autoloader.dirs, "#{app_path}/extras" + end + + test "clear reloads the main autoloader, and does not reload the once one" do + boot + + $zeitwerk_integration_reload_test = [] + + autoloader = Rails.autoloader + def autoloader.reload + $zeitwerk_integration_reload_test << :autoloader + super + end + + once_autoloader = Rails.once_autoloader + def once_autoloader.reload + $zeitwerk_integration_reload_test << :once_autoloader + super + end + + ActiveSupport::Dependencies.clear + + assert_equal %i(autoloader), $zeitwerk_integration_reload_test + end +end diff --git a/railties/test/generators/app_generator_test.rb b/railties/test/generators/app_generator_test.rb index 1ee9e43e89..937b8eb427 100644 --- a/railties/test/generators/app_generator_test.rb +++ b/railties/test/generators/app_generator_test.rb @@ -660,6 +660,15 @@ class AppGeneratorTest < Rails::Generators::TestCase assert_gem "jbuilder" end + def test_inclusion_of_zeitwerk + run_generator + if RUBY_ENGINE == "ruby" + assert_gem "zeitwerk" + else + assert_no_gem "zeitwerk" + end + end + def test_inclusion_of_a_debugger run_generator if defined?(JRUBY_VERSION) || RUBY_ENGINE == "rbx" diff --git a/railties/test/isolation/abstract_unit.rb b/railties/test/isolation/abstract_unit.rb index 0e8e0e86ee..47d42645c6 100644 --- a/railties/test/isolation/abstract_unit.rb +++ b/railties/test/isolation/abstract_unit.rb @@ -421,6 +421,10 @@ module TestHelpers file_name end + def app_dir(path) + FileUtils.mkdir_p("#{app_path}/#{path}") + end + def remove_file(path) FileUtils.rm_rf "#{app_path}/#{path}" end @@ -487,7 +491,11 @@ Module.new do # Fake 'Bundler.require' -- we run using the repo's Gemfile, not an # app-specific one: we don't want to require every gem that lists. contents = File.read("#{app_template_path}/config/application.rb") - contents.sub!(/^Bundler\.require.*/, "%w(turbolinks webpacker).each { |r| require r }") + if RUBY_ENGINE == "ruby" + contents.sub!(/^Bundler\.require.*/, "%w(turbolinks webpacker zeitwerk).each { |r| require r }") + else + contents.sub!(/^Bundler\.require.*/, "%w(turbolinks webpacker).each { |r| require r }") + end File.write("#{app_template_path}/config/application.rb", contents) require "rails" diff --git a/railties/test/railties/engine_test.rb b/railties/test/railties/engine_test.rb index 851407dede..69f6e34d58 100644 --- a/railties/test/railties/engine_test.rb +++ b/railties/test/railties/engine_test.rb @@ -704,25 +704,27 @@ YAML RUBY @plugin.write "app/controllers/bukkits/foo_controller.rb", <<-RUBY - class Bukkits::FooController < ActionController::Base - def index - render inline: "<%= help_the_engine %>" - end + module Bukkits + class FooController < ActionController::Base + def index + render inline: "<%= help_the_engine %>" + end - def show - render plain: foo_path - end + def show + render plain: foo_path + end - def from_app - render inline: "<%= (self.respond_to?(:bar_path) || self.respond_to?(:something)) %>" - end + def from_app + render inline: "<%= (self.respond_to?(:bar_path) || self.respond_to?(:something)) %>" + end - def routes_helpers_in_view - render inline: "<%= foo_path %>, <%= main_app.bar_path %>" - end + def routes_helpers_in_view + render inline: "<%= foo_path %>, <%= main_app.bar_path %>" + end - def polymorphic_path_without_namespace - render plain: polymorphic_path(Post.new) + def polymorphic_path_without_namespace + render plain: polymorphic_path(Post.new) + end end end RUBY |