ruby-on-railsrubydecoratorrails-enginesruby-on-rails-7

Rails 7 controller decorator uninitialised constant error in production only


I am getting the following error zeitwerk/loader/helpers.rb:95:in const_get': uninitialized constant Controllers::BasePublicDecorator (NameError) This is an error in a local production console using rails c -e production but not an issue in development which works perfectly.

In an engine, CcsCms::PublicTheme, I have a decorator I am using to extend the controller of another CcsCms::Core engine and it is this decorator that is causing the error.

public_theme/app/decorators/decorators/controllers/base_public_decorator.rb

CcsCms::BasePublicController.class_eval do
  before_action :set_theme #ensure that @current_theme is available for the
                          #header in all public views

  private

    def set_theme
      @current_theme = CcsCms::PublicTheme::Theme.current_theme
    end
end

This functionality is working perfectly in development but fails in production with an error as follows

The controller I am trying to decorate in the CcsCms::Core engine is CcsCms::BasePublicController.rb

module CcsCms
  class BasePublicController < ApplicationController
    layout "ccs_cms/layouts/public"

    protected
      def authorize
      end
  end
end

in the theme engine with the decorator I am trying to use I have a Gemfile that defines the core engine as follows

gem 'ccs_cms_core', path: '../core'

In the ccs_cms_public_theme.gemspec I am requiring the core engine as a dependency

  spec.add_dependency "ccs_cms_core"

in the engine.rb I am requiring the core engine and loading the decorator paths in a config.to_prepare do block

require "deface"
require 'ccs_cms_admin_dashboard'
require 'ccs_cms_custom_page'
require 'ccs_cms_core'
require 'css_menu'
#require 'tinymce-rails'
require 'delayed_job_active_record'
require 'daemons'
require 'sprockets/railtie'
require 'sassc-rails'

module CcsCms
  module PublicTheme
    class Engine < ::Rails::Engine
      isolate_namespace CcsCms::PublicTheme
      paths["app/views"] << "app/views/ccs_cms/public_theme"

      initializer "ccs_cms.assets.precompile" do |app|
        app.config.assets.precompile += %w( public_theme_manifest.js )
      end

      initializer :assets do |config|
        Rails.application.config.assets.paths << root.join("")
      end

      initializer :append_migrations do |app|
        unless app.root.to_s.match?(root.to_s)
          config.paths['db/migrate'].expanded.each do |p|
            app.config.paths['db/migrate'] << p
          end
        end
      end

      initializer :active_job_setup do |app|
        app.config.active_job.queue_adapter = :delayed_job
      end

      config.to_prepare do
        Dir.glob(Engine.root.join("app", "decorators", "**", "*_decorator*.rb")) do |c|
          Rails.configuration.cache_classes ? require(c) : load(c)
        end
      end

      config.generators do |g|
        g.test_framework :rspec,
          fixtures: false,
          request: false,
          view_specs: false,
          helper_specs: false,
          controller_specs: false,
          routing_specs: false
        g.fixture_replacement :factory_bot
        g.factory_bot dir: 'spec/factories'
      end

    end
  end
end

Given that my decorator is given the same name as the controller it is decorating from the core engine but with the .decorator extension I am pretty certain that is everything hooked up correctly, as mentioned, this works perfectly in development but I am unable to start a rails console in a production environment due to this error. It seems that the class_eval is failing somehow and I can only think that this may be a path issue but I can not figure it out

UPDATE After quite a big learning curve, thank's muchly to @debugger comments and @Xavier Noria answer it is clear that my issue comes down to Zeitworks autoload functionality

Rails guides here has an interesting and appealing solution to me

Another use case are engines decorating framework classes:

initializer "decorate ActionController::Base" do  
> ActiveSupport.on_load(:action_controller_base) do
>     include MyDecoration   end end

There, the module object stored in MyDecoration by the time the initializer runs becomes an ancestor of ActionController::Base, and reloading MyDecoration is pointless, it won't affect that ancestor chain.

But maybe this isn't the right solution, I again failed to make it work with the following

  initializer "decorate CcsCms::BasePublicController" do
    ActiveSupport.on_load(:ccs_cms_base_public_controller) do
      include CcsCms::BasePublicDecorator
    end
  end

Generating the following error

zeitwerk/loader/callbacks.rb:25:in `on_file_autoloaded': expected file /home/jamie/Development/rails/comtech/r7/ccs_cms/engines/public_theme/app/decorators/controllers/ccs_cms/base_public_decorator.rb to define constant Controllers::CcsCms::BasePublicDecorator, but didn't (Zeitwerk::NameError)

So back to the solution provided here, thank's again for the answer below I tried the following which did work finally

  config.to_prepare do
    overrides = Engine.root.join("app", "decorators")
    Rails.autoloaders.main.ignore(overrides)
    p = Engine.root.join("app", "decorators")
    loader = Zeitwerk::Loader.for_gem
    loader.ignore(p)
    Dir.glob(Engine.root.join("app", "decorators", "**", "*_decorator*.rb")) do |c|
      Rails.configuration.cache_classes ? require(c) : load(c)
    end
  end

Solution

  • Problem here is that when lazy loading, nobody is referencing a constant called ...::BasePublicDecorator. However, Zeitwerk expects that constant to be defined in that file, and the mismatch is found when eager loading.

    The solution is to configure the autoloader to ignore the decorators, because you are handling their loading, and because they do not define constants after their names. This documentation has an example. It needs to be adapted to your engine, but you'll see the idea.

    For completeness, let me also explain that in Zeitwerk, eager loading is a recursive const_get, not a recursive require. This is to guarantee that if you access the constant, loading succeeds or fails consistently in both modes (and it is also a tad more efficient). Recursive const_get still issues require calls via Module#autoload, and if you ran one for some file idempotence also applies, but Zeitwerk detects the expected constant is not defined anyway, which is an error condition.