Using TypeScript for Stimulus - Changing importmaps to esbuild

Part 23 of building a Rails 7 application

Up until this point all the Stimulus controllers have been written in plain JavaScript. I would prefer to switch over to TypeScript instead. There are any number of articles espousing the benefits, but for me, anything that prevents a bug from being introduced is better than fixing bugs later.

Currently all my JavaScript modules have been included using the Rails 7 "importmaps" feature. This is great as it does not involve any build steps, but that becomes a problem when I want to start using TypeScript. I now need a step to convert the TypeScript code to JavaScript.

So an alternative way to use JavaScript in a Rails 7 application is esbuild using jsbundling-rails. Noel Rappin over at Rails 7 and JavaScript goes into a lot more detail on the various alternative options, it is an excellent read.

In order to convert from importmaps to esbuild and then use TypeScript I need to make some changes:

Install the jsbundling-rails gem

Gemfile

gem 'jsbundling-rails', '~> 1.0'

Follow the instructions for the gem but as I want to use esbuild I needed to do:

rails javascript:install:esbuild

Set up package.json

All the JavaScript libraries I want to use must now be added as dependencies. I'm using yarn to manage this. This was already in place to use Storybook.

I also want to have TypeScript monitor my code so it will automatically build the JavaScript assets after tsc confirms there are no problems. If there are issues it will automatically delete any existing built JavaScript assets. For that I have added a dev script and added this to my Procfile.dev that foreman launches.

package.json

{
  "scripts": {
    "storybook": "start-storybook",
    "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=assets",
    "failure:js": "rm -f ./app/assets/builds/application.js && rm -f ./app/assets/builds/application.js.map",
    "dev": "tsc-watch --noClear -p tsconfig.json --onSuccess \"yarn build\" --onFailure \"yarn failure:js\""
  },
  "devDependencies": {
    "@storybook/addon-controls": "^6.5.9",
    "@storybook/server": "^6.5.9",
    "@tsconfig/recommended": "^1.0.1",
    "tsc-watch": "^5.0.3",
    "typescript": "^4.7.4"
  },
  "dependencies": {
    "@hotwired/stimulus": "^3.1.0",
    "@hotwired/turbo": "^7.1.0",
    "@hotwired/turbo-rails": "^7.1.3",
    "@rails/request.js": "^0.0.6",
    "cash-dom": "^8.1.1",
    "el-transition": "^0.0.7",
    "esbuild": "^0.14.51",
    "stimulus-use": "^0.50.0"
  }
}

Procfile.dev

web: bin/rails server -p 3000
css: bin/rails tailwindcss:watch
js: yarn dev

Set up a tsconfig.json file The includes section needs to specify where to look for code, so I need to make sure it looks in my components folder for the controllers associated with my ViewComponents.

tsconfig.json

{
  "extends": "@tsconfig/recommended/tsconfig.json",
  "compilerOptions": {
    "target": "ES2015",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "downlevelIteration": true
  },
  "$schema": "<https://json.schemastore.org/tsconfig>",
  "display": "Recommended",
  "include": [
    "./app/javascript/*.ts",
    "./app/javascript/**/*.ts",
    "./app/components/**/*.ts"
  ],
  "exclude": [
    "./node_modules"
  ]
}

Modify the JavaScript manifest file

For the JavaScript assets I only need the contents of the builds folder now. If I leave the //= link_tree ../../javascript .js and //= link_tree ../../components .js sections in there I will get a Sprockets::DoubleLinkError because it is trying to include both the built application.js and my source one under app/javascript.

app/assets/config/manifest.js

Before:

//= link_tree ../images
//= link_directory ../stylesheets .css
//= link_tree ../../javascript .js
//= link_tree ../../../vendor/javascript .js
//= link_tree ../builds
//= link_tree ../../components .js

After:

//= link_tree ../images
//= link_directory ../stylesheets .css
//= link_tree ../../../vendor/javascript .js
//= link_tree ../builds

Add a declarations file

There are going to be some JavaScript libraries that don't have TypeScript types defined for them. I have two, "el-transition" and "@rails/request.js". In order to suppress the error messages regarding these I can declare them as modules in a separate file. Ideally these would contain all the type definitions for these modules but I don't have the time to address that right now, perhaps at a later date (or maybe someone else will!).

app/javascript/decs.d.ts

declare module "el-transition"
declare module "@rails/request.js"

interface Window {
    Stimulus: any;
}

The last part of that file is to resolve an error in this bit of code in the javascript\controllers\application.ts file. There is no Stimulus property on the browser window so I need to "fake" it's existence for my application. For reference the error is error TS2339: Property 'Stimulus' does not exist on type 'Window & typeof globalThis'.

javascript\controllers\application.ts

// Configure Stimulus development experience
application.debug = false
window.Stimulus   = application

export { application }

Adding support for ViewComponent Stimulus controllers

Because I'm not using "importmaps" anymore Rails doesn't automatically know about the Stimulus controllers, and especially does not know about the ones that are associated with my ViewComponents. For controllers that live under app\javascript\controllers there already exists a task with Rails to generate an index.ts file that includes them.

I need to run rake stimulus:manifest:update every time a new controller is added, or one is removed. However this won't find my ViewComponent controllers. Fortunately the user chunlea came up with this way (github.com/github/view_component/issues/106..) which produces a separate index.ts file that will include my ViewComponent controllers too.

lib/tasks/view_component.rake

namespace :view_component do
  namespace :stimulus_manifest do
    task display: :environment do
      puts Stimulus::Manifest.generate_from(Rails.root.join('app/components'))
    end

    task update: :environment do
      manifest =
        Stimulus::Manifest.generate_from(Rails.root.join('app/components'))

      File.open(Rails.root.join('app/components/index.js'), 'w+') do |index|
        index.puts('// This file is auto-generated by ./bin/rails view_component:stimulus_manifest:update')
        index.puts('// Run that command whenever you add a new controller in ViewComponent')
        index.puts
        index.puts(%(import { application } from "../javascript/controllers/application"))
        index.puts(manifest)
      end
    end
  end
end

if Rake::Task.task_defined?('stimulus:manifest:update')
  Rake::Task['stimulus:manifest:update'].enhance do
    Rake::Task['view_component:stimulus_manifest:update'].invoke
  end
end

Here is what that generated index.ts looks like:

// This file is auto-generated by ./bin/rails view_component:stimulus_manifest:update
// Run that command whenever you add a new controller in ViewComponent

import { application } from "../javascript/controllers/application"

import CollectionFilter__ComponentController from "./collection_filter/component_controller"
application.register("collection-filter--component", CollectionFilter__ComponentController)

import CollectionPager__ComponentController from "./collection_pager/component_controller"
application.register("collection-pager--component", CollectionPager__ComponentController)

import Modal__ComponentController from "./modal/component_controller"
application.register("modal--component", Modal__ComponentController)

import Notification__ComponentController from "./notification/component_controller"
application.register("notification--component", Notification__ComponentController)

import ResourceForm__ComponentController from "./resource_form/component_controller"
application.register("resource-form--component", ResourceForm__ComponentController)

Modify the application.js file

The application.js needs to specify the location for my controllers differently now. And it also needs to include my ViewComponent controllers.

app/javascript/application.js

Before:

// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"

After:

// Entry point for the build script in your package.json
import "@hotwired/turbo-rails"
import "./controllers"
import "../components"

Convert all the existing JavaScript files over to TypeScript

Some examples of what needed to be changed (besides changing the file extension to .ts):

Stimulus actions call their methods with a parameter of type ActiveEvent: app/javascript/controllers/resource_controller.ts

export default class extends Controller {
  navigate(event: ActionEvent) {
    window.location = event.params.url
  }
  ...
}

But in some cases it is necessary to intersect them with a more specific type, like a keydown event for example. This allows access to the key but also to the params that contain the controller parameters. app/javascript/controllers/editor_controller.ts

export default class extends Controller {
  cancel(event: ActionEvent & KeyboardEvent) {
    if (event.key === 'Escape' || event.key === 'Esc') {
      event.preventDefault();
      const resourceUrl = event.params.url
      const attribute = event.params.attribute
      const resourceName = event.params.resourceName

      get(`${resourceUrl}?${resourceName}[${attribute}]=`, { responseKind: 'turbo-stream' })
    }
  }
}

As per the TypeScript documentation for Stimulus the values and targets need to be declared. Here is an example of a value: app/components/collection_filter/component_controller.ts

export default class extends Controller {
  static values = {
    searchFormId: String
  }

  declare readonly searchFormIdValue: string;
  ...
}

And another one for targets: app/components/notification/component_controller.ts

export default class extends Controller {
  static targets = ["container", "notification"]

  declare readonly containerTarget: HTMLElement;
  declare readonly notificationTarget: HTMLElement;
  ...

n order to use methods that exist on HTML forms I need to cast the results of my dom element lookups (which I use cash for). app/javascript/controllers/resource_controller.ts

    const form: HTMLFormElement = cash(`#${formId}`)[0] as HTMLFormElement
    const formData: FormData = new FormData(form);
    if (form.checkValidity()) {
      ...
    }

I'm using the dispatch method in Stimulus to emit events that can be picked up elsewhere, in particular for error handling. I need to define an interface for that: app/javascript/controllers/errors_controller.ts

interface ErrorEvent {
  detail: {
      resourceName: string,
      errors: string[]
  }
}

export default class extends Controller {
  show({detail: {resourceName, errors}}: ErrorEvent) {
    for (const [field, messages] of Object.entries(errors)) {
      const messageTag = cash(`#${resourceName}_${field}--server_side_invalid_message`)
      messageTag.empty()
      for (const message of messages) {
        messageTag.append(`<p class="validation">${field} ${message}</p>`)
      }
    }
  }
}

Fix the JavaScript inclusion tag in <head>

My page <head> at the moment is using the "importmaps" javascript_importmap_tags helper method to include all the files. This won't work now. Instead I go back to a regular javascript_include_tag

app/views/application/_head.html.erb

Before:

<head>
  <title>CatalogueCleanser</title>
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <%= csrf_meta_tags %>
  <%= csp_meta_tag %>
  <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>

  <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
  <%= javascript_importmap_tags %>
</head>

After:

<head>
  <title>CatalogueCleanser</title>
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <%= csrf_meta_tags %>
  <%= csp_meta_tag %>
  <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>

  <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
  <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
</head>

So after all that I can run my test suite and see what happens:

% rails test:all
yarn install v1.22.19
[1/4] 🔍  Resolving packages...
success Already up-to-date.
✨  Done in 0.40s.
yarn run v1.22.19
$ esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=assets

  app/assets/builds/application.js      270.3kb
  app/assets/builds/decs.d.js              71b 
  app/assets/builds/application.js.map  514.9kb
  app/assets/builds/decs.d.js.map          93b 

✨  Done in 0.44s.
+ ./.rvm/gems/ruby-3.0.2@rails7/gems/tailwindcss-rails-2.0.10-x86_64-darwin/exe/x86_64-darwin/tailwindcss -i ./rails/catalogue_cleanser/app/assets/stylesheets/application.tailwind.css -o ./rails/catalogue_cleanser/app/assets/builds/tailwind.css -c ./rails/catalogue_cleanser/config/tailwind.config.js --minify

Done in 599ms.
Run options: --seed 28134

# Running:

DEBUGGER: Attaching after process 31067 fork to child process 31077
Capybara starting Puma...
* Version 5.6.4 , codename: Birdie's Version
* Min threads: 0, max threads: 4
* Listening on http://127.0.0.1:52245
...............................................................................................................................................................................................................................................................................................

Finished in 41.724653s, 6.8784 runs/s, 9.8024 assertions/s.
287 runs, 409 assertions, 0 failures, 0 errors, 0 skips
Coverage report generated for Unit Tests to .../catalogue_cleanser/coverage. 672 / 702 LOC (95.73%) covered.

All passing, hooray! And now I get the added benefit of TypeScript safety when writing my Stimulus controller code.