46

I created new rails 7 project rails new my_project and have a problem to include my custom JS file to be processed by rails.

my "javascript/application.js"

import "@hotwired/turbo-rails"
import "controllers"

import "chartkick"
import "Chart.bundle"
import "custom/uni_toggle"

my custom JS file: "javascript/custom/uni_toggle.js"

function uniToggleShow() {
    document.querySelectorAll(".uni-toggle").forEach(e => e.classList.remove("hidden"))
}

function uniToggleHide() {
    console.log("uni toggle hide")
    document.querySelectorAll(".uni-toggle").forEach(e => e.classList.add("hidden"))
}

window.uniToggleShow = uniToggleShow
window.uniToggleHide = uniToggleHide

I'm using in my layout <%= javascript_importmap_tags %>

and my "config/importmap.rb"

pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
pin_all_from "app/javascript/controllers", under: "controllers"

4 Answers 4

111
1. Quickstart            - quick things you should know
2. `pin_all_from`        - a few details
3. `pin`                   ...
4. Run in a console      - when you need to figure stuff out
5. Relative imports      - don't do it, unless you want to
6. Examples              - to make it extra clear

If you're not using importmap-rails, really, you should not have any issues. Add a file then import "./path/to/file". Make sure to run bin/dev to compile your javascript if you're using jsbundling-rails.

If you're using importmap-rails, there is no compilation, every single file has to be served individually in development and production, and every import has to be mapped to a url for browser to fetch.

pin and pin_all_from is a rails way of constructing an importmap. Imports are mapped to local files through an asset url. So just keep in mind, import "something" could map to url /assets/file-123.js which could map to file app/some_asset_path/file.js or in production public/assets/file-123.js:

<script type="importmap" data-turbo-track="reload">{
  "imports": {
    "application":                  "/assets/application-da9b182f12cdd2de0b86de67fc7fde8d1887a7d9ffbde46937f8cd8553cb748d.js",
    "@hotwired/turbo-rails":        "/assets/turbo.min-49f8a244b039107fa6d058adce740847d31bdf3832c043b860ebcda099c0688c.js",
    "@hotwired/stimulus":           "/assets/stimulus-a1299f07b3a1d1083084767c6e16a178a910487c81874b80623f7f2e48f99a86.js",
    "@hotwired/stimulus-loading":   "/assets/stimulus-loading-6024ee603e0509bba59098881b54a52936debca30ff797835b5ec6a4ef77ba37.js",
    "controllers/application":      "/assets/controllers/application-44e5edd38372876617b8ba873a82d48737d4c089e5180f706bdea0bb7b6370be.js",
    "controllers/hello_controller": "/assets/controllers/hello_controller-29468750494634340c5c12678fe2cdc3bee371e74ac4e9de625cdb7a89faf11b.js",
    "controllers":                  "/assets/controllers/index-e70bed6fafbd4e4aae72f8c6fce4381d19507272ff2ff0febb3f775447accb4b.js",
  }#    ^                             ^
   #    |                             |
   #  names you use to import        urls browser uses to get it
   #    |                             ^ 
   #    |                             |
   #    `------>  mapped to  ---------'
}</script>

Once you have an importmap you have to import the things you need. Importmap doesn't load anything, it is just a configuration.


Quickstart

Let's say we've added a plugin directory:

app/
└── javascript/
   ├── application.js   # <= imports go here and other js files
   └── plugin/
      ├── app.js
      └── index.js
config/
└── importmap.rb        # <= pins go here

Pin a single file:

# config/importmap.rb
pin "plugin/app"
pin "plugin/index"

# app/javascript/application.js
import "plugin/app"     # which maps to a url which maps to a file
import "plugin/index"

or pin all the files in plugin directory and subdirectories:

# config/importmap.rb
pin_all_from "app/javascript/plugin", under: "plugin"

# app/javascript/application.js
import "plugin/app"
import "plugin"         # will import plugin/index.js

Do not use relative imports, such as import "./plugin/app", it may work in development, but it will break in production.
See the output of bin/importmap json to know what you can import and verify importmap.rb config.

Do not precompile in development, it will serve precompiled assets from public/assets which do not update when you make changes.
Run bin/rails assets:clobber to remove precompiled assets.

In case something doesn't work, app/javascript directory has to be in:
Rails.application.config.assets.paths
and app/assets/config/manifest.js as //= link_tree ../../javascript .js


Pinning your files doesn't make them load. They have to be imported in application.js:

// app/javascript/application.js
import "plugin"

Alternatively, if you want to split up your bundle, you can use a separate module tag in your layout:

<%= javascript_import_module_tag "plugin" %>

or templates:

<% content_for :head do %>
  <%= javascript_import_module_tag "plugin" %>
<% end %>

# add this to the end of the <head> tag:
# <%= yield :head %>

You can also add another entrypoint in addition to application.js, say you've added app/javascript/admin.js. You can import it with all the pins:

# this doesn't `import` application.js anymore
<%= javascript_importmap_tags "admin" %>

Because application pin has preload: true option set by default it will issue a request to load application.js file, even when you override application entrypoint with admin. Preloading and importing are two separate things, one does not cause the other. Remove preload option to avoid unnecessary request.


pin_all_from(dir, under: nil, to: nil, preload: false)

Pins all the files in a directory and subdirectories.

https://github.com/rails/importmap-rails/blob/v1.1.2/lib/importmap/map.rb#L33

def pin_all_from(dir, under: nil, to: nil, preload: false)
  clear_cache
  @directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload)
end

dir - Path relative to Rails.root or an absolute path.

Options:

:under - Optional[1] pin prefix. Required if you have index.js file.

:to - Optional[1] path to asset. Falls back to :under option. Required if :under is omitted. This path is relative to Rails.application.config.assets.paths.

:preload - Adds a modulepreload link if set to true:

<link rel="modulepreload" href="/assets/turbo-5605bff731621f9ca32b71f5270be7faa9ccb0c7c810187880b97e74175d85e2.js">
  1. note: either :under or :to is required

To pin all the files in the plugin directory:

pin_all_from "app/javascript/plugin", under: "plugin"

# NOTE: `index.js` file gets a special treatment, instead
#       of pinning `plugin/index` it is just `plugin`.
{
  "imports": {
    "plugin/app": "/assets/plugin/app-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js",
    "plugin": "/assets/plugin/index-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js"
  }
}

Here is how it all fits together:
(if something doesn't work, take your options and follow the arrows, especially the path_to_asset part, you can try it in the console, see below)

   "plugin/app": "/assets/plugin/app-04024382391bb...4145d8113cf788597.js"
#   ^      ^      ^
#   |      |      |  
# :under   |      `-path_to_asset("plugin/app.js")
#          |                       ^      ^
#          |                       |      |
#          |..       (:to||:under)-'      |
#  "#{dir}/app.js"                        |
#          '''''`-------------------------'             

:to option might not be obvious here. It is useful if :under option is changed, which will make path_to_asset fail to find app.js.

For example, :under option can be anything you want, but :to option has to be a path that asset pipeline, Sprockets, can find (see Rails.application.config.assets.paths) and also precompile (see app/assets/config/manifest.js).

pin_all_from "app/javascript/plugin", under: "@plug", to: "plugin"

# Outputs these pins
#
#   "@plug/app": "/assets/plugin/app-04024382391b1...16beb14ce788597.js"
#   "@plug": "/assets/plugin/index-04024382391bb91...4ebeb14ce788597.js"
#
# and can be used like this
#
#   import "@plug";
#   import "@plug/app";

Specifying absolute path will bypass asset pipeline:

pin_all_from("app/javascript/plugin", under: "plugin", to: "/plugin")

#   "plugin/app": "/plugin/app.js"
#   "plugin": "/plugin/index.js"
#
# NOTE: It is up to you to set up `/plugin/*` route and serve these files.

pin(name, to: nil, preload: false)

Pins a single file.

https://github.com/rails/importmap-rails/blob/v1.1.2/lib/importmap/map.rb#L28

def pin(name, to: nil, preload: false)
  clear_cache
  @packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload)
end

name - Name of the pin.

Options:

:to - Optional path to asset. Falls back to {name}.js. This path is relative to Rails.application.config.assets.paths.

:preload - Adds a modulepreload link if set to true


When pinning a local file, specify name relative to app/javascript directory (or vendor or any other asset directory).

pin "plugin/app"
pin "plugin/index"

{
  "imports": {
    "plugin/app": "/assets/plugin/app-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js",
    "plugin/index": "/assets/plugin/index-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js"
  }
}

Here is how it fits together:

   "plugin/app": "/assets/plugin/app-04024382391bb...16cebeb14ce788597.js"
#   ^             ^
#   |             |  
#  name           `-path_to_asset("plugin/app.js")
#                                  ^
#                                  |
#              (:to||"#{name}.js")-'

If you want to change the name of the pin, :to option is required to give path_to_asset a valid file location.

For example, to get the same pin for index.js file as the one we get from pin_all_from:

pin "plugin", to: "plugin/index"

{
  "imports": {
    "plugin": "/assets/plugin/index-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js"
  }
} 

Run in a console

You can mess around with Importmap in the console, it's faster to debug and learn what works and what doesn't:

>> helper.path_to_asset("plugin/app.js")
=> "/assets/plugin/app-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js"

>> map = Importmap::Map.new
>> map.pin_all_from("app/javascript/plugin", under: "plugin")
>> puts map.to_json(resolver: helper)
{
  "imports": {
    "plugin/app": "/assets/plugin/app-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js",
    "plugin": "/assets/plugin/index-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js"
  }
}

>> map.pin("application")
>> puts map.to_json(resolver: helper)
{
  "imports": {
    "application": "/assets/application-8cab2d9024ef6f21fd55792af40001fd4ee1b72b8b7e14743452fab1348b4f5a.js"
  }
}

# Importmap from config/importmap.rb
>> Rails.application.importmap

Relative/absolute imports

Relative/absolute imports could work, if you make the correct mapping:

# config/importmap.rb
pin "/assets/plugin/app", to: "plugin/app.js"
// app/javascript/application.js
import "./plugin/app"

application.js is mapped to digested /assets/application-123.js, because ./plugin/app is relative to /assets/application-123.js, it should be correctly resolved to /assets/plugin/app which has an importmap that we made with our pin:

"/assets/plugin/app": "/assets/plugin/app-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js",

This should also just work:

// app/javascript/plugin/index.js
import "./app"

However, while import-maps support all the relative and absolute imports, this doesn't seem to be the intended use case in importmap-rails.


Examples

This should cover just about everything:

.
├── app/
│   └── javascript/
│       ├── admin.js
│       ├── application.js
│       ├── extra/
│       │   └── nested/
│       │       └── directory/
│       │           └── special.js
│       └── plugin/
│           ├── app.js
│           └── index.js
└── vendor/
    └── javascript/
        ├── downloaded.js
        └── package/
            └── vendored.js

Output is from running bin/importmap json:

# this is the only time when both `to` and `under` options can be omitted
# you don't really want to do this, at least not for `app/javascript`
pin_all_from "app/javascript"
pin_all_from "vendor/javascript"

"admin":                          "/assets/admin-761ee3050e9046942e5918c64dbfee795eeade86bf3fec34ec126c0d43c931b0.js",
"application":                    "/assets/application-d0d262731ff4f756b418662f3149e17b608d2aab7898bb983abeb669cc73bf2e.js",
"extra/nested/directory/special": "/assets/extra/nested/directory/special-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js",
"plugin/app":                     "/assets/plugin/app-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js",
"plugin":                         "/assets/plugin/index-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js",
"downloaded":                     "/assets/downloaded-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js",
"package/vendored":               "/assets/package/vendored-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js"

Note the difference:

pin_all_from "app/javascript/extra", under: "extra"    # `to: "extra"` is implied
"extra/nested/directory/special": "/assets/extra/nested/directory/special-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js"
 ^

pin_all_from "app/javascript/extra", to: "extra"
"nested/directory/special": "/assets/extra/nested/directory/special-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js"
 ^

pin_all_from "app/javascript/extra", under: "@name", to: "extra"
"@name/nested/directory/special": "/assets/extra/nested/directory/special-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js"
 ^

Note the pattern:

pin_all_from "app/javascript"
pin_all_from "app/javascript/extra",                  under: "extra"
pin_all_from "app/javascript/extra/nested",           under: "extra/nested"
pin_all_from "app/javascript/extra/nested/directory", under: "extra/nested/directory"
pin_all_from "app/javascript/extra",                  to: "extra"
pin_all_from "app/javascript/extra/nested",           to: "extra/nested"
pin_all_from "app/javascript/extra/nested/directory", to: "extra/nested/directory"
pin_all_from "app/javascript/extra",                  under: "@name", to: "extra"
pin_all_from "app/javascript/extra/nested",           under: "@name", to: "extra/nested"
pin_all_from "app/javascript/extra/nested/directory", under: "@name", to: "extra/nested/directory"

Same exact thing works for vendor:

pin_all_from "vendor/javascript/package", under: "package"
# etc

Single files are easy:

pin "admin"
pin "application"
pin "extra/nested/directory/special"
pin "@extra/special", to: "extra/nested/directory/special"

pin "downloaded"
pin "renamed", to: "downloaded"

When pin_all_from fails:

# if you ever tried this, it doesn't work:
# pin_all_from "app/javascript", under: "@app", to: ""
# but it can be done:
app_js = Rails.root.join("app/javascript")
app_js.glob("**/*.js").each do |path|
  name = path.relative_path_from(app_js).to_s.chomp(".js")
  pin "@app/#{name}", to: name
end
# useful for things like `app/components`
16
  • 14
    This post deserves all the upvotes. Thank you. Commented Apr 6, 2023 at 12:09
  • @cesoid if you want to make relative imports work, see the very last code snippet to get an idea of what to do outside of the defaults. but nothing stopping you from making import "pallete" or import "@fractal/pallete" work: pin_all_from "app/javascript/fractal_viewer", to: "fractal_viewer" or pin_all_from "app/javascript/fractal_viewer", under: "@fractal", to: "fractal_viewer". but consider that changing your imports for an hour is easier than debugging some edge case issues with relative imports. you'd be better off sticking with esbuild in that case.
    – Alex
    Commented Apr 8, 2023 at 1:31
  • 1
    @Alex Thanks. I ended up doing this: def pin_all_relative(dir_name) ; pin_all_from "app/javascript/#{dir_name}", under: "#{Rails.application.config.assets.prefix}/#{dir_name}", to: dir_name ; end and then calling it once per "component". To explain: My site has over 200 of its own modules organized into directories several layers deep, and even the deepest ones will sometimes import without even referencing their parent directory. In total there are about 600 import statements which I would have to change without a simple find/replace, and I would lose a bit of flexibility and reusability.
    – cesoid
    Commented Apr 13, 2023 at 19:26
  • 9
    This answer is like a missing manual for importmaps. Commented Sep 28, 2023 at 15:20
  • 3
    This has to be in the official Rails docs. Especially the part about relative imports from Rails.application.config.assets.paths. It's not obvious IMHO. Thank you for all the effort you put to this comment Commented Dec 10, 2023 at 17:36
27

Was also having trouble adding custom JS files in my Rails 7 app. I even followed DHH video --> https://www.youtube.com/watch?v=PtxZvFnL2i0 but still was facing difficulties. The following steps worked for me:

  1. Go to config/importmap.rb and add the following:

    pin_all_from "app/javascript/custom", under: "custom"

  2. Go to app/javascript/application.js file and add the following:

    import "custom/main"

  3. In 'app/javascript' directory, add 'custom' folder.

  4. In 'app/javascript/custom' directory add your custom js file 'main.js'.

  5. Run In your terminal:

    rails assets:precompile

  6. Start your rails server. Voilà 👍

6
  • 4
    this kind of works but running rails assets:precompile after any edits to a custom JS file cannot be the correct behaviour
    – depassion
    Commented Apr 13, 2022 at 15:09
  • 2
    @depassion I think that video is a little old now. You don't have to precompile anymore. Commented Jun 14, 2022 at 9:33
  • I found this question and followed everything here, the javascript file but what worked for me was putting the custom javasript at the bottom of my view file. So what is the difference or the "right" way?? Commented Aug 1, 2022 at 22:23
  • The 2nd point shold be import "./custom/main" Commented Nov 17, 2022 at 2:42
  • 2
    no it should not. an import like "./custom/main" end up pointing directly to the assets folder. they will break in production.
    – johncip
    Commented Feb 11, 2023 at 1:10
22

After watching DHH video I found the last piece of the puzzle.

To make my custom JS code work, I just added this line to the "confing/importmap.rb"

pin_all_from "app/javascript/custom", under: "custom"
1
  • so in this way, we add our js file in the "importmap", but what if I have multiple layouts and both have different js files? Commented Dec 22, 2022 at 12:12
2

If you want to use importmap, do what people have answer before.

But if you add the file in the importmap it means that in every layout, the file will be loaded, but if you only want to add single JS file, my suggestion is to use a simple , with javascript_include_tag like this:

<%= javascript_include_tag 'filename' %>

Not the answer you're looking for? Browse other questions tagged or ask your own question.