Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save AnalyzePlatypus/3305d91593a0146a8ea23953c76a8949 to your computer and use it in GitHub Desktop.
Save AnalyzePlatypus/3305d91593a0146a8ea23953c76a8949 to your computer and use it in GitHub Desktop.
Rails + ViewComponent: Component Stimulus.js files

Rails ViewComponent: Component Stimulus.js controllers

ViewComponent is a Rails library for building reusable front-end components.

I prefer organizing a component's files like this:

app/
├─ components/
   ├─ table/
      ├─ table_component.rb
      ├─ table_component.html.erb
      ├─ table_component.css
      ├─ table_component_controller.js

Traditionally, ViewComponents do not include CSS or JS files. However, I prefer co-locating them. This makes the component easy to reason about.

This requires a few custom changes, as ViewComponent does not provide an out-of-the box way to serve CSS or JavaScript files.

For my CSS setup, see this gist.

Including JavaScript from ViewComponents

ViewComponent already supports the generation of Stimulus controllers:

rails g component Example --stimulus

But there's no built in way to load component controllers into the page.

TL;DR:

  1. Add all components to the asset pipeline.
  2. Pin all component controllers to the Import Map
  3. Import and register nested controllers in index.js

1. Add all components to the asset pipeline.

This makes Sprockets pick up the controller files. If you're using Propshaft, there's probably an equivalent syntax that does the same thing.

# config/application.rb
class Application < Rails::Application
  config.assets.paths.push(*Rails.root.glob("app/components/**/")) # See config/importmap.rb
end

2. Pin all component controllers to the Import Map

# config/importmap.rb

app_js = Rails.root.join("app/components")
app_js.glob("**/*.js").each do |path|
  name = path.relative_path_from(app_js).to_s
  pin "controllers/#{name}", to: name
end

3. Import and register nested controllers in index.js

Instead of calling the Stimulus standard loader eagerLoadControllersFrom, I wrote a custom loader eagerLoadNestedControllersFrom. I adapted it from the Stimulus source code.

Why the heck would you write your own loader? Are you insane?

The main benefit is that it rewrites the controller names nicely.

For example, a controller app/components/table_component/table_component_controller.js would ordinarily be called table/table-component by Stimulus. So you'd use it like this:

<!-- app/components/table_component/table_component.html.erb -->
<div data-controller='table/table-component' data-action='click->table/table-component#clicked'>
</div>

Such repetition! Yuck! 🤮

My custom loader neatly rewrites the controller name to simply table:

<div data-controller='table' data-action='click->table#clicked'>
</div>

Ahh! Much better 😌

Custom Loader source code

// javascript/eager_load_nested_controllers.js
/*
  Adopted from the Stimulus source at
  https://github.com/hotwired/stimulus-rails/blob/3e168f07ba73248740f9dee2a49463893fd5be57/app/assets/javascripts/stimulus-loading.js
*/
 
 const controllerAttribute = "data-controller";
 
 const registeredControllers = {};
 
 export default function eagerLoadNestedControllersFrom(under, application) {
   const paths = Object.keys(parseImportmapJson()).filter((path) => {
     const isMatch = path.match(new RegExp(`^controllers/.*_controller(.js)*$`));
 
     return isMatch;
   });
   paths.forEach((path) => registerControllerFromPath(path, under, application));
 }
 
 const REGEXP = {
   CONTROLLER_SUFFIX: "_controller",
   COMPONENT_SUFFIX: "_component",
   ALL_SLASHES: /\//g,
   JS_EXTENSION: /.js$/,
   DIRECTORY_PREFIX: /.*--/g,
   ALL_UNDERSCORES: /_/g,
 };
 
 function registerControllerFromPath(path, under, application) {
   const name = path
     .replace(new RegExp(`^${under}/`), "")
     .replace(REGEXP.CONTROLLER_SUFFIX, "")
     .replace(REGEXP.COMPONENT_SUFFIX, "")
     .replace(REGEXP.ALL_SLASHES, "--")
     .replace(REGEXP.JS_EXTENSION, "")
     .replace(REGEXP.DIRECTORY_PREFIX, "")
     .replace(REGEXP.ALL_UNDERSCORES, "-");
 
   // console.log(`Rewrote "${path}" -> "${name}"`);
 
   if (!(name in registeredControllers)) {
     import(path)
       .then((module) => {
         // console.log(`Registering ${name}`);
         registerController(name, module, application);
       })
       .catch((error) =>
         console.error(`Failed to register controller: ${name} (${path})`, error)
       );
   }
 }
 
 function parseImportmapJson() {
   return JSON.parse(document.querySelector("script[type=importmap]").text)
     .imports;
 }
 
 function registerController(name, module, application) {
   if (!(name in registeredControllers)) {
     application.register(name, module.default);
     registeredControllers[name] = true;
   }
 }

Use the loader to register all controllers, including component controllers:

// app/javascript/controllers.index.js
import eagerLoadNestedControllersFrom from "eager_load_nested_controllers";
  
eagerLoadNestedControllersFrom("controllers", application);

🎉 4. Done!

Any Stimulus controller added to app/components/**/*_controller.js will now be seamlessly loaded into the application!

Appendix: Other ViewComponent settings

In environments/development.rb I prefer these settings:

# ViewComponent options
config.view_component.generate.stimulus_controller = true
config.view_component.component_parent_class = "BaseComponent"

# ViewComponent instrumentation for performance analysis
config.view_component.instrumentation_enabled = true
config.view_component.use_deprecated_instrumentation_name = false

# Show ViewComponents in Rack::MiniProfiler
Rack::MiniProfilerRails.subscribe("render.view_component") do |_name, start, finish, _id, payload|
  Rack::MiniProfilerRails.render_notification_handler(
    Rack::MiniProfilerRails.shorten_identifier(payload[:identifier]),
    finish,
    start
  )
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment