rails, stimulus, engine

How to configure Rails Engine to work with Stimulus

I started working on an open-sourced version of occson and decided to prepare it as a Rails Engine. Before commencing work, I asked myself about Stimulus. Does it work in the Rails Engine?
I have searched a bit online and have not found any satisfactory answers to my question. Therefore, I also took some time to solve this issue and prepared a tutorial on how to deal with the configuration and adaptation of the Rails Engine to work with Stimulus.
Let us begin by showcasing the technology stack. We will be working with the latest version of Ruby on Rails, namely 7.1.2. As well as with Ruby version 3.2.2.

Engine

Let us create a space for our Rails Engine

mkdir hello_world && cd hello_world

I personally use rbenv to manage the ruby environment, so let's set a predefined ruby version in our space for the Rails Engine

echo 3.2.2 > .ruby-version

Now let's install the gem manager and the latest Ruby on Rails

gem install bundler rails

Then, let's generate the Rails Engine
rails plugin new . --mountable --database=sqlite3 --no-skip-javascript

In order for our Rails Engine to work in developer mode, we will need to complete some information in gemspec. In the hello_world.gemspec file, we need to specify:
  • homepage
  • summary
  • description
  • allowed_push_host
  • source_code_uri
  • changelog_uri

In my case, it will look like as follows:

require_relative "lib/hello_world/version"

Gem::Specification.new do |spec|
  spec.name        = "hello_world"
  spec.version     = HelloWorld::VERSION
  spec.authors     = ["Tomasz Kowalewski"]
  spec.email       = ["me@tkowalewski.pl"]
  spec.homepage    = "https://hello-world.com"
  spec.summary     = "Summary of HelloWorld."
  spec.description = "Description of HelloWorld."
  spec.license     = "MIT"

  # Prevent pushing this gem to RubyGems.org. To allow pushes either set the "allowed_push_host"
  # to allow pushing to a single host or delete this section to allow pushing to any host.
  spec.metadata["allowed_push_host"] = "https://rubygems.org"

  spec.metadata["homepage_uri"] = spec.homepage
  spec.metadata["source_code_uri"] = "https://github.com/tkowalewski/hello_world"
  spec.metadata["changelog_uri"] = "https://github.com/tkowalewski/hello_world/blob/main/CHANGELOG.md"

  spec.files = Dir.chdir(File.expand_path(__dir__)) do
    Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"]
  end

  spec.add_dependency "rails", ">= 7.1.2"
end

Add CHANGELOG.md
touch CHANGELOG.md

Time to add importmap-rails and stimulus-rails to hello_world.gemspec.
spec.add_dependency "importmap-rails", "~> 1.2", ">= 1.2.3"
spec.add_dependency "stimulus-rails", "~> 1.3"

Install all necessary dependencies
bundle install

The next step is to configure the javascript manifest accordingly. However, as we will be storing the controllers in app/assets/javascripts we need to update our manifest to include a file tree scan.
In the file app/assets/config/hello_world_manifest.js we replace the
//= link_directory ../javascripts/hello_world .js

to
//= link_tree ../javascripts/hello_world .js

We will now create our test stimulus controller. in the file app/assets/javascripts/hello_world/test_controller.js let's place:
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    console.log("Connected")
    this.element.textContent = "Test"
  }
}

By creating a controller for a Rails application (app/controllers/hello_world/home_controller.rb)
module HelloWorld
  class HomeController < ApplicationController
    def index; end
  end
end

And we will define routines for it (config/routes.rb)
HelloWorld::Engine.routes.draw do
  root to: 'home#index'
end

In the view (app/views/hello_world/home/index.html.erb) let's assign the handling to be done by our stimulus controller
<div data-controller="test"></div>

Then, in the application layout (app/views/layouts/hello_world/application.html.erb) in the head section, add a tag for importmap
<%= javascript_importmap_tags %>

After that, let us create a configuration for importmap by creating a config/importmap.rb file with the contents of
pin_all_from File.expand_path("../app/assets/javascripts", __dir__)

Let's not forget to configure our Rails Engine, so in the lib/hello_world/engine.rb file we should add the following initializers
module HelloWorld
  class Engine < ::Rails::Engine
    isolate_namespace HelloWorld

    initializer 'hello_world-engine.importmap', before: 'importmap' do |app|
      app.config.importmap.paths += [Engine.root.join('config/importmap.rb')]
    end

    initializer "hello_world-engine.assets" do |app|
      app.config.assets.precompile += %w[hello_world_manifest.js]
    end
  end
end

Dummy application

Finally, let's move on to our dummy application and configure it to work with importmaps and stimulus. So let's add importmap-rails and stimulus-railsto the Gemfile
gem 'importmap-rails', '~> 1.2', '>= 1.2.3'
gem 'stimulus-rails', '~> 1.3'

Installing gems
bundle install

We are installing the importmaps and stimulus for our dummy application
cd test/dummy && bin/rails importmap:install stimulus:install

Now we have to fix the directory structure in our dummy application, so that the application dummy javascript manifest does not attempt to load the contents from a non-existent folder.
mkdir app/assets/javascripts && touch app/assets/javascripts/.keep

The final step is to load our test controller (app/assets/javascripts/hello_world/test_controller.js) in the dummy application. In order for the dummy application (or another application in which we will use our Rails Engine) to "see" our controller, we need to add controllers to the Rails Engine in test/dummy/app/javascript/controllers/index.js. We do this by adding
eagerLoadControllersFrom("hello_world", application)

So that our test/dummy/app/javascript/controllers/index.js will look like this
// Import and register all your controllers from the importmap under controllers/*

import { application } from "controllers/application"

// Eager load all controllers defined in the import map under controllers/**/*_controller
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
eagerLoadControllersFrom("hello_world", application)

// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!)
// import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
// lazyLoadControllersFrom("controllers", application)

The final step is to launch the server
cd ../../ && bin/rails s

And the result of our work should be the content Test rendered by our test stimulus controller (app/assets/javascripts/hello_world/test_controller.js) on the web page at http://localhost:3000/hello_world.
008_01.png 228 KB


Rails application

Ok, we have tested our Rails Engine in a dummy application. Now, let's define the implementation steps for a real Rails application.
First, we add our Rails Engine as a gem to the Gemfile of the respective project
gem "hello_world"

Installing gems
bundle install

We install the Rails Engine in config/routes.rb
mount HelloWorld::Engine => "/hello_world"

We load the stimulus controllers in app/javascript/controllers/index.jsby adding the
eagerLoadControllersFrom("hello_world", application)

And we can enjoy a working Rails Engine :)