Avoid using require_dependency in modules_for_helpers if Zeitwerk is enabled#37632
Avoid using require_dependency in modules_for_helpers if Zeitwerk is enabled#37632casperisfine wants to merge 2 commits intorails:masterfrom
require_dependency in modules_for_helpers if Zeitwerk is enabled#37632Conversation
There was a problem hiding this comment.
This is coupling actionpack with railties. I think it would be better to inject this value in the railtie and use it here.
|
Why does this code do all that dance? |
|
@fxn I kinda agree with you, but wasn't bold enough to go against that very specific comment stating the autoload exceptions should be wrapped. I'll dig a bit more into this, to see if I can write it in a way that works with both autoloaders without an |
705baf1 to
f5c196a
Compare
|
@fxn @rafaelfranca I think I found a way to stop using |
|
Awesome, I'll have a look soon! |
There was a problem hiding this comment.
I'm not super happy with that regexp match, but it's the only way I found to support both autoloaders.
Classic raises a #<LoadError: Unable to autoload constant Admin::UsersHelper, expected /Users/byroot/src/github.com/Shopify/rails/actionpack/test/fixtures/helpers_typo/admin/users_helper.rb to define it>
While Zeitwerk raises a #<Zeitwerk::NameError: Unable to autoload constant Admin::UsersHelper, expected /Users/byroot/src/github.com/Shopify/rails/actionpack/test/fixtures/helpers_typo/admin/users_helper.rb to define it>
Ideally I could segregate those with a rescue LoadError, Zeitwerk::NameError, however it would add a dependency on Zeitwerk in actionpack.
There was a problem hiding this comment.
How about something like this?
rescue *[(Zeitwerk::NameError if defined?(Zeitwerk::NameError)].compactf5c196a to
ae7c233
Compare
|
Quick comment from my phone. If a dependency of an app uses Zeitwerk, the constant is defined regardless of the app choice. |
|
@fxn yes, but at the very least it's not defined when running action pack test suite. Maybe that doesn't properly reflect reality? After some quick testing I think it does: >> require 'action_pack'
=> true
>> defined? Zeitwerk
=> nil |
ae7c233 to
70c159a
Compare
|
👋 @fxn if you're still very busy it's fine, but I just wanted to make sure this didn't fall through the cracks 😉 |
|
@casperisfine thanks for the heads-up! I'll move it up. |
|
I have read the patch and discussion, and I'd like to explore some small changes. I'll write back. |
|
This is what I have in mind (draft, untested): diff --git a/actionpack/lib/abstract_controller/helpers.rb b/actionpack/lib/abstract_controller/helpers.rb
index abb09456e0..ca66588bae 100644
--- a/actionpack/lib/abstract_controller/helpers.rb
+++ b/actionpack/lib/abstract_controller/helpers.rb
@@ -11,17 +11,20 @@ module Helpers
class_attribute :_helper_methods, default: Array.new
end
- class MissingHelperError < LoadError
- def initialize(error, path)
- @error = error
- @path = "helpers/#{path}.rb"
+ class MissingHelperError < StandardError
+ def initialize(error, module_name)
+ @module_name = module_name
set_backtrace error.backtrace
+ end
- if /^#{path}(\.rb)?$/.match?(error.path)
- super("Missing helper file helpers/%s.rb" % path)
- else
- raise error
- end
+ def message
+ <<-MSG.strip_heredoc
+ Helper #{@module_name} not found. Please check:
+
+ * Does app/helpers/#{@module_name.underscore}.rb exist?
+
+ * If it does, does it define a module called #{@module_name}?
+ MSG
end
end
@@ -148,24 +151,17 @@ def clear_helpers
def modules_for_helpers(args)
args.flatten.map! do |arg|
case arg
- when String, Symbol
- file_name = "#{arg.to_s.underscore}_helper"
- begin
- require_dependency(file_name)
- rescue LoadError => e
- raise AbstractController::Helpers::MissingHelperError.new(e, file_name)
- end
-
- mod_name = file_name.camelize
+ when Symbol, String
+ arg = arg.to_s
+ module_name = arg >= "a" ? "#{arg}_helper".camelize : arg
begin
- mod_name.constantize
- rescue LoadError
- # dependencies.rb gives a similar error message but its wording is
- # not as clear because it mentions autoloading. To the user all it
- # matters is that a helper module couldn't be loaded, autoloading
- # is an internal mechanism that should not leak.
- raise NameError, "Couldn't find #{mod_name}, expected it to be defined in helpers/#{file_name}.rb"
- end
+ module_name.constantize
+ rescue NameError => error
+ if error.missing_name?(module_name)
+ raise MissingHelperError.new(error, module_name)
+ end
+ raise
when Module
arg
else
Rationale:
Note that the code inflects with What do you think? (EDIT: I removed a recursive call for symbols, because I forgot the argument has to be an array and you get an array back. The code was uglier than a |
b5d2596 to
3c24119
Compare
I wasn't sure what I thought, so I went ahead and made tests pass on your diff as f2b939790b837b53ec9af804d512cfca9c9b4ef0
So overall I think Étienne's suggestion of conditionally rescuing |
3c24119 to
f2b9397
Compare
|
On one hand, I believe we have to depart from working with files as much as possible. For example, take this existing method: def default_helper_module!
module_name = name.sub(/Controller$/, "")
module_path = module_name.underscore
helper module_path
rescue LoadError => e
raise e unless e.is_missing? "helpers/#{module_path}_helper"
rescue NameError => e
raise e unless e.missing_name? "#{module_name}Helper"
endIn my view, nowadays would be better written as (drafted inline, untested): def default_helper_module!
helper name.sub(/Controller$/, "Helper")
# Error handling.
endWe could even encourage usage of constant names or module objects as But I guess we have to continue supporting lowercase arguments, at least as long as Right now, Action Pack inflects with |
|
Quick idea I have not validated: What if we try to address this one using absolute paths in the appropriate spots while we are in the transition phase with both autoloaders? The inflector issue is still there (it was already there), but perhaps that would be an easier way to make this code compatible with |
47a9ad9 to
6f71a00
Compare
6f71a00 to
a35b9ef
Compare
|
@fxn I rebased and update the PR using @etiennebarrie 's idea. The test suite is now green. If you still think the code isn't good, I can try your absolute path idea, but I'm not super kin on it because as I understand it, it means doing a |
|
ACK! |
|
Hey! I'll work on this one today or tomorrow. |
|
I think this is all too complicated and I believe For starters, helpers do not need to be defined in
so the error message appending Then, we have more stuff to cover:
We could perhaps refine introducing branches that check the error messages, but I don't like going that way. I would like to explore an approach in which we depart from Let me explore that a little bit. |
Yeah, I didn't try that because I though it would be some kind of API change, but if that's an acceptable change then yeah that would simply this code tremendously. |
|
Just a heads up. I have been working on this today, but need to understand the scope of the public API for file names to see how far we can push an implementation based on As an extreme use case, the test suite does this: ActionController::Base.helpers_path = File.expand_path("../fixtures/helpers", __dir__)and several controllers override @casperisfine do you have a monkey patch in Shopify to be able to disable |
I don't but I could. I'll probably add one in a few days to validates that this is indeed the last blocker. |
|
This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. |
|
I don't see a way to change The current implementation does not even require helpers to be in autoload paths, you can point helpers to arbitrary directories, as the test suite does. As long as But I believe that implementation is not aligned with the direction Rails has taken, and in particular with The solution I have in mind is something like this (untested): def modules_for_helpers(args)
args.flatten.map! do |arg|
case arg
when Module
arg
when String, Symbol
arg = arg.to_s
arg = arg.camelize unless arg.match?(/\A[A-Z]/)
arg << "Helper"
arg.constantize
else
raise ArgumentError, "helper must be a String, Symbol, or Module"
end
end
endThat camelizes instead of underscore. It bases loading the helpers on autoloading, which is the way to load code I'd like to push for. That is, you think in terms of constant paths, rather than file names, and you program as if all application classes and modules were in memory. That is the mindset. In particular, if def default_helper_module!
module_name = name.sub(/Controller\z/, "Helper")
helper(module_name)
rescue LoadError => e
raise e unless e.is_missing?("helpers/#{module_name.underscore}")
rescue NameError => e
raise e unless e.missing_name?(module_name)
endIt would be even more aligned to accept only module objects and blocks as argumentsand deprecate the rest, but The cases in which this is backwards incompatible are going to be edge, I believe. We could do by documenting in the CHANGELOG and upgrade guide. Meanwhile, I wonder if Rails could manually add helper folders to |
That's pretty much the monkey patch we're using to be able to set
I agree. |
|
It's in |
|
My pleasure. I'm currently updating Rails in our app to include this commit. I'll let you know if there's something wrong. I'm currently battling with an extremely weird error, but have no idea if it's a legit problem or if there's something weird in our code base. But maybe you might have an idea, I reduced it down to: $ bin/rails r 'p InventoryImport.name; p InventoryImport::MailerHelper'
"InventoryImport"
uninitialized constant #<Class:0x00007fe02a9f3190>::MailerHelperSo somehow However when calling Anyway, I'll keep digging, but it's super weird. |
|
Ok, so I really don't get why the name isn't properly included in the message, but regardless I think this condition should be relaxed because the exception doesn't always include the strict constant name, example: [11] pry(main)> Shop::DoesNotExist
NameError: uninitialized constant Shop (call 'Shop.connection' to establish a connection)::DoesNotExist |
Something that comes to mind is that we ignore overridden |
|
I think I found the root cause. That I'll try to figure a fix for that root cause. |
|
Ok, so I have good reasons to believe that it's a problem specific to our app because we're not properly unhooking AS::Dependencies. I'll keep digging, but sorry for the scare :) |
|
Cool :). |
|
Actually I'm able to reproduce it in a fairly vanilla Rails 6 app. => Shipit::Stack (call 'Shipit::Stack.connection' to establish a connection)
>> Shipit::Stack.const_get('FooHelper')
Traceback (most recent call last):
2: from (irb):3
1: from (irb):3:in `const_get'
NameError (uninitialized constant #<Class:0x00007ff96822ef20>::FooHelper)What made me believe we didn't unhook AS::Dep properly was this minimal repro script: require 'rails'
ActiveSupport::Dependencies.unhook! if ENV['UNHOOK']
class Foo
class << self
def name
"#{super}(Lolilol)"
end
end
end
"Foo::Bar".constantizeBut it doesn't seem accurate. |
|
Ok, I found a better repro script. It's not require 'rails'
ActiveSupport::Dependencies.unhook! if ENV['UNHOOK']
class Foo
class << self
def inspect
"#{super}(Lolilol)"
end
end
end
"Foo::Bar".constantizeThis time it fails when AS::Dep is unhooked, just like what I experience. I can also reduce it further without loading AS at all: So it seems like AS::Dep was actually giving better error message, and |
|
Interesting, looks like that is what Ruby does: class Foo
class << self
def inspect
"Yo"
end
end
end
Foo::X # => foo.rb:9:in `<main>': uninitialized constant Yo::X (NameError) |
|
Yeah, I checked MRI's source and it's what it does. Might be worth a ticket upstream, because that's crap. Anyway, I don't think it's much of a problem because I'm working on a PR, I should be opening it shortly, I'll be tagging you. |
Followup to: #37599
Context
I'm trying to disable
config.add_autoload_paths_to_load_pathin our app, which causerequire_dependencyto straight up fail when called with an autoloaded path.This change
This is the last
require_dependencyusage. With this patch I'm able to boot our app withconfig.add_autoload_paths_to_load_path = false.Again, I'm not super happy with accessing
Rails.autoloaders.zeitwerk_enabled?from there, but I can't see a better alternative.@fxn, @rafaelfranca
cc @Edouard-chin @etiennebarrie