How to add custom JS file to new rails 7 project

1. TLDR                  - 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 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 how imports are mapped to local files through an asset url. So just keep in mind, import "not_a_file" could map to url /assets/file-123.js which could map to file app/some_asset_path/file.js or public/assets/file-123.js.


TLDR

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"

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 "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:
(js = Rails.root.join("app/javascript")).glob("**/*.js").each do |path|
  name = path.relative_path_from(js).to_s.chomp(".js")
  pin "@app/#{name}", to: name
end
# useful for things like `app/components`

Leave a Comment