I’m coming back to Rails (7.1) after a long hiatus from it and I’m struggling a bit with how to configure Javascript for my purposes. I’ve tried using importmap-rails (the default) and also jsbundling-rails using Bun. Neither is working for my case.
I guess I’m trying to confirm here is:
Am I doing this idiomatically?; and, if so;
How am I messing up here?
I’m trying to create a set of shared partials that I can use as reusable “components” across different pages. Some of these need Javascript.
My partials are stored under views/shared and the associated js is under javascript/shared.
To use an example, say Header, I would create a view/shares/_header.html.erb and a javascript/shared/header.js file.
header.js would have a class like export default Header { ... } and I want to consume that class inside _header.html.erb.
Using the importmaps path, I’ve added import "shared" to application.js and pin_all_from "app/javascript/shared", under: "shared" to importmap.rb.
Using the jsbundling path, I add import "./shared" to application.js and I create a javascript/shared/index.js for the module that contains import Header from "./header". I’ve also tried exporting from the index.js and also application.js. I tried multiple variations of the above and other things too.
Whenever I try to instantiate Header from the partial I get the following error: Uncaught ReferenceError: Header is not defined. It feels like the class is being bundled correctly as I can see it in the outputted bundle, etc, but Rails can’t see the class.
Can you be more explicit about what you’re doing in the partial? In general I would shy away from using JS in your HTML—it should be tucked away in a Stimulus controller.
For example, in my importmap.rb I have:
pin_all_from "app/javascript/lib", under: "lib", to: "lib"
I’m developing my first Rails app using JavaScript import-maps/modules. I started with my scripts embedded in the HTML, which were working. But after modularizing these, I was shaken to the core when I found that I couldn’t directly use in my HTML any JavaScript function that I properly preloaded and imported into application.js. Nor could I import them into the HTML, getting a “can only use import in a module” error.
I’ve got past this using:
<%= javascript_tag type: :module do %>
<do import>
addEventListener("DOMContentLoaded", () => {
<call import>(ERB args...);
});
<% end %>
But the fact that there’s no javascript_module_tag helper, and Trevor’s comment that he “would shy away from using JS in your HTML—it should be tucked away in a Stimulus controller”, suggests that this isn’t a common solution.
So how do you now get arguments from Ruby into JavaScript? Embed them in the HTML so a module can pick them up? Same problem with Stimulus, which I had deferred learning until I got everything working with my usual raw JavaScript.
The imports in application.js use side-effect mode. I have to find out how they use this to hook in to the HTML JavaScript.
The “Working with JavaScript in Rails” guide needs to discuss this aspect of modularized JavaScript, because it’s unexpected for those used to non-module JavaScript includes.
The typical solution is storing any relevant data that JS needs in data attributes on the related elements. This is directly supported in Stimulus via values although the principal has long precedeed Stimulus using the paradigm of unobtrusive JavaScript.
Unobtrusive JavaScript was first moving it from HTML-tag event attributes to separate in-page script blocks by using addEventListener. I guess it’s now levelled-up to getting scripts out of the HTML altogether.
Again, the Rails JavaScript guide should discuss this for old-timers like me.
I guess it’s now levelled-up to getting scripts out of the HTML altogether.
This is also good from a security perspective. Although you can use the nonce option to secure JS in a page, but IMHO it’s still cleaner to have the JS external.
Thanks for your information about the nonce attribute of in-page scripts. It looks to be a backup for sanitization of user content. Do these nonces stop the use of browser extensions or user-scripts that inject scripts, unless the CSP response header can also be messed with?
I’ve managed to use HTML parameters to just about remove in-page scripts from two of my pages. There’s one in-page script required by a third-party library, which thankfully didn’t need to use any of my in-module functions. But there’s sure to be cases when you’re forced to use module functions in-page, which is why I’m surprised of how obscure the solution of an in-page module is, without a dedicated Rails helper.
The scripts on my third page are riddled with ERB parameters and conditional inclusion, so the work involved in moving them to modules will not only make things more complex and inefficient, but even with multiple modules conditionally loaded in the head, I’ll be loading and exposing code that is inapplicable to the current user.
Also, it’s not well documented that Rails can have multiple import maps to avoid exposing all your scripts to every user, even scripts dedicated to special users. This is actually less secure than either in-page or selectively head-included scripts.
I would say there is nothing wrong also with keeping things the way they have been. The new hotness is not taking away anything so if you don’t feel it’s applicable to you feel free to keep doing it the way you have been.
Also if you do need to make your JS more dynamically generated keep in mind you don’t have to do it within HTML. For example, you could have a .js.erb file instead of a .html.erb file to dynamically render JS and import that as a module. You don’t get all the caching goodness but it might be way to transfer certain a bunch of data client-side or conditionally pull in certain code.
Thanks for the suggestion to use a js.erb file. I actually started creating one, but abandoned it, probably because of the caching issue and an assumption that the resulting file couldn’t be a module outside the import map. But it now does seem to be the best option to keep the source of my third page clean, which is important for this page of this application.
Actually, I hadn’t abandoned using a js.erb. I was pondering the tradeoff of having this rendered in-page, which keeps the html.erb source small but makes the final HTML page messy, and sourcing this JavaScript in the head through a separate action, which causes a separate fetch but keeps the rendered page simple.
A solution may be to parametize the JS action path into a cacheable set.