Skip to content

Commit

Permalink
Feat(Performance): Use Zeitwerk for loading decorators
Browse files Browse the repository at this point in the history
This is inspired by solidusio#60.

We can leverage Zeitwerk's `on_load` hook and it's capacity of knowing
which constant a file should define in order to load decorators,
including when reloading.

This should greatly speed up reloading, as only those decorators that
are needed for the current request are loaded.

However, there is a few restrictions that come with this:

1. All decorators MUST use a Zeitwerk-compatible naming scheme
2. All decorators MUST use Module.prepend, where Module is the fully
   qualified class name being modified.

Co-Authored-By: [email protected]
  • Loading branch information
mamhoff committed Nov 20, 2024
1 parent 49282e5 commit cca2824
Showing 1 changed file with 37 additions and 6 deletions.
43 changes: 37 additions & 6 deletions lib/solidus_support/engine_extensions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

module SolidusSupport
module EngineExtensions
# Matches e.g. "Spree::Order.prepend"
DECORATED_CLASS_PATTERN = /(?<decorated_class>[A-Z][a-zA-Z:]+)(\.prepend[\s(])/

include ActiveSupport::Deprecation::DeprecatedConstantAccessor
deprecate_constant 'Decorators', 'SolidusSupport::EngineExtensions', deprecator: SolidusSupport.deprecator

Expand Down Expand Up @@ -46,14 +49,42 @@ def load_solidus_subscribers_from(path)
end
end

# Loads decorator files.
# Loads decorators.
#
# This is needed since they are never explicitly referenced in the application code and
# won't be loaded by default. We need them to be executed regardless in order to decorate
# existing classes.
def load_solidus_decorators_from(path)
path.glob('**/*.rb') do |decorator_path|
load(decorator_path)
# won't be loaded by default. We need them to be executed whenever the decorated class is reloaded.
def load_solidus_decorators_from(base_path)
# This will be Zeitwerk.
autoloader = Rails.autoloaders.main
base_path.glob('**/*.rb') do |path|
# Match all the classes that are prepended in the file
matches = File.read(path).scan(DECORATED_CLASS_PATTERN).flatten

# Don't do a thing if there's no prepending.
next unless matches.present?

# For each unique match, make sure we load the decorator when the base class is loaded
matches.uniq.each do |decorated_class|
# Zeitwerk tells us which constant it expects a file to provide.
decorator_constant = autoloader.cpath_expected_at(path)

# If the class to be decorated has already been loaded, it won't be autoloaded later,
# so we have to directly load the decorator. Reloading is taken care of in the on_load hook.
if Object.const_defined?(decorated_class)
Rails.logger.debug("Loading #{decorator_constant} in order to modify #{decorated_class}")
decorator_constant.constantize
end

# Sprinkle some debugging.
Rails.logger.debug("Preparing to autoload #{decorated_class} with #{decorator_constant}")

# For every class name being autoloaded, we can add a hook to load the decorator when the base class is loaded.
# Multiple hooks are no problem, as long as all decorators are namespaced appropriately.
autoloader.on_load(decorated_class) do |base|
Rails.logger.debug("Loading #{decorator_constant} in order to modify #{base}")
decorator_constant.constantize
end
end
end
end

Expand Down

0 comments on commit cca2824

Please sign in to comment.