How does importmaps-rails replace yarn correctly in rails 8?

I am confused about how importmaps is supposed to replace yarn. Particularly I’m having difficulty with the underscore npm package. I did the following as the docs have said to use the same package name as on npm but with the pin command:

 ./bin/importmap pin underscore

It correctly added underscore.js to /vendor/javascript and added the pin to my importmap config.

pin "underscore" # @1.13.7

But when I try to import the functionality I need like this:

import debounce from 'underscore';

I just get a ton of errors about missing files:

Started GET "/assets/index-default.js" for ::1 at 2024-12-20 13:19:59 -0500
13:19:59 web.1  | Started GET "/_/CzPGziMj.js" for ::1 at 2024-12-20 13:19:59 -0500
13:19:59 web.1  | Started GET "/assets/_setup.js" for ::1 at 2024-12-20 13:19:59 -0500
13:19:59 web.1  | Started GET "/assets/restArguments.js" for ::1 at 2024-12-20 13:19:59 -0500
13:19:59 web.1  | Started GET "/assets/isObject.js" for ::1 at 2024-12-20 13:19:59 -0500
13:20:00 web.1  |   
13:20:00 web.1  | ActionController::RoutingError (No route matches [GET] "/_/CzPGziMj.js"):
13:20:00 web.1  |   
13:20:01 web.1  | Started GET "/assets/isUndefined.js" for ::1 at 2024-12-20 13:20:01 -0500
13:20:01 web.1  | Started GET "/assets/isBoolean.js" for ::1 at 2024-12-20 13:20:01 -0500
13:20:01 web.1  | Started GET "/assets/isElement.js" for ::1 at 2024-12-20 13:20:01 -0500
13:20:01 web.1  | Started GET "/assets/isString.js" for ::1 at 2024-12-20 13:20:01 -0500
13:20:06 web.1  | Started GET "/assets/isNumber.js" for ::1 at 2024-12-20 13:20:06 -0500
13:20:06 web.1  | Started GET "/assets/isDate.js" for ::1 at 2024-12-20 13:20:06 -0500
13:20:06 web.1  | Started GET "/assets/isRegExp.js" for ::1 at 2024-12-20 13:20:06 -0500
13:20:06 web.1  | Started GET "/assets/isError.js" for ::1 at 2024-12-20 13:20:06 -0500
13:20:07 web.1  | Started GET "/assets/isNull.js" for ::1 at 2024-12-20 13:20:07 -0500
13:20:07 web.1  | Started GET "/assets/isArrayBuffer.js" for ::1 at 2024-12-20 13:20:07 -0500
13:20:07 web.1  | Started GET "/assets/isDataView.js" for ::1 at 2024-12-20 13:20:07 -0500
13:20:07 web.1  | Started GET "/assets/isArray.js" for ::1 at 2024-12-20 13:20:07 -0500
13:20:07 web.1  | Started GET "/assets/isFunction.js" for ::1 at 2024-12-20 13:20:07 -0500
13:20:08 web.1  | Started GET "/assets/isArguments.js" for ::1 at 2024-12-20 13:20:08 -0500
13:20:08 web.1  | Started GET "/assets/isFinite.js" for ::1 at 2024-12-20 13:20:08 -0500
13:20:08 web.1  | Started GET "/assets/isNaN.js" for ::1 at 2024-12-20 13:20:08 -0500
13:20:08 web.1  | Started GET "/assets/isTypedArray.js" for ::1 at 2024-12-20 13:20:08 -0500
13:20:08 web.1  | Started GET "/assets/isEmpty.js" for ::1 at 2024-12-20 13:20:08 -0500
...

And it makes sense looking at the actual downloaded file, it just exports the default functions from a bunch of files with relative paths that were not downloaded with underscore.js:

// underscore@1.13.7 downloaded from https://ga.jspm.io/npm:underscore@1.13.7/modules/index-all.js

export{default}from"./index-default.js";import"../_/CzPGziMj.js";export{VERSION}from"./_setup.js";export{default as restArguments}from"./restArguments.js";export{default as isObject}from"./isObject.js";export{default as isNull}from"./isNull.js";export{default as isUndefined}from"./isUndefined.js";export{default as isBoolean}from"./isBoolean.js";...

I used to use yarn, and looking at what was downloaded in node_modules when this was working, I see underscore.js along with its minified versions and various others in the top level of an underscore folder, with all the missing files in a subfolder called modules.

How is importmaps-rails supposed to work managing dependencies like this? Is there a way I can tell it to download an entire package the way you would with yarn or npm? Or does it rely on serving from a cdn? I’d prefer not to rely on cdns, but I’d also like to not check in every single dependency to my repository.

I thought that importmaps could handle dependency management and versioning the same way we used yarn. I see that ./bin/importmap pin X replaces yarn add X it does not actually download the full package it seems. And I can’t find an equivalent to yarn install for someone who is checking out the repository. This question is from someone with exactly the same requirements as me but I couldn’t get the --download flag to work with underscore.

Thanks for any help!

This makes my spidey-sense tingle. Shouldn’t it be something more like:

import { debounce } from 'underscore'

I haven’t used “underscore” in a while but I assume debounce is just one function of underscore not the entire lib. :wink:

It’s hard for me to read the minimized underscore there so not sure but in my experience not all modules in NPM are converted correctly from the “node”-style modules to standard ES modules by jspm.io so that might also be a factor.

Thanks for the suggestion, but no such luck I’m afraid. Adding in the curly brackets got me the same result. Also tried removing the semi-colon as you did and also got the same result.

I think there is fundamentally a path issue with the way the downloaded underscore.js file is trying to access other files.

Which leads me back to my more general questions:

  • Does importmaps have to use cdns or does it download files? Can you specify one or the other, or specify where files are downloaded? I’m a little unclear on exactly what the pin command can do?
  • Can importmaps function like yarn with a blanket install all dependencies command that someone can do instead of a accessing a cdn or checking external packages into version control with the rest of your code?

Thanks again for the help!

I think it’s less of a path issue but more module format issue. The JS space is a mess with several different module formats (AMD, UMD, CommonJS, ES modules) and annoyingly the standards compliant one (which is what we need for import maps since we are using the browser native function to import modules) is sort of the least well supported by the NPM world.

I believe jspm.io is attempting to make any npm package available in ES format (i.e. the standards-compliant format) but it doesn’t always work depending on the package. In fact, I think I remember getting into that same wormhole myself with underscore trying to use its debounce method and in the end went with a separate “debounce” package because underscore was giving me so many problem.

Hopefully with time these kinks will get worked out as ES modules start to gain some dominance. But we have a lot of legacy code out there.

I believe the importmap script Rails provides can do either but by default it downloads to vendor/javascripts and that is added as part of your repo (not git-ignored).

I’m not a fan of the “add to your repo” strategy also and prefer to install deps as part of the build process so followed the basic process here (with one minor bug correction I noted) with success.

--download option has been remove. Pinning should just always download by dhh · Pull Request #217 · rails/importmap-rails · GitHub

Now I use esbuild to handle some dependencies.

I am considering returning to bundling because the js ecosystem is still built around builders. To reach no-build, you need to reduce dependencies as much as possible, like the 37signals project.

Oh I didn’t notice that was you in that thread haha! I linked that same thread earlier in my original post.

Yes it seems the imports within underscore weren’t working because it was not an es module. I was able to link to the esm version using yarn, so I think I will continue to go that way for now.

I added node_modules to my asset paths in application.rb:

config.assets.paths << Rails.root.join('node_modules')

Pinned underscore manually using the esm version:

# config/importmap.rb
pin "underscore", to: "underscore/underscore-esm.js"

Then I could import just the one function I needed, and it correctly pulled in its dependencies:

// my-module.js:
import { debounce } from "underscore"

Here’s my importmap:

{
  "imports": {
    "application": "/assets/application-bd82d6ab.js",
    "underscore": "/assets/underscore/underscore-esm-91fff580.js",
    "src/image_modal_methods": "/assets/src/image_modal_methods-5c3fcc95.js",
    "src/search": "/assets/src/search-8b38146d.js",
    "src/sortable-table": "/assets/src/sortable-table-8ef04aa1.js"
  }
}

So, I like that. I can use yarn to manage dependencies but link to them with importmaps. No need for webpacker. So far so good anyway, only need a few packages for this project.

1 Like

Ah good to know, that explains why the download flag wasn’t working for me.

So how does esbuild fit into your workflow?

I write in this post Importmap or jsbundling? I use both

But now I think it’s a bit redundant. Just use esbuild, set multiple entrypoints and split the code to take advantage of the cache.

Interesting idea. I still really like the idea of not having node at all. But I admit the rails importmap utility is a bit janky in some respects and the jspm.io service isn’t always good about auto-converting to an ES module.

If I can help it I’ll probably stick with the rails utility but it’s nice to have a backup when that doesn’t work.

This was the same function I needed and I just instead switched to this impl. It worked fine with the Rails utility.