Raft Demo / Documentation

Javascript

  1. Javascript
  2. Importmaps for engines
  3. Svelte components
    1. Overview
    2. Setup and configuration
      1. Directory Structure
      2. vite_ruby
      3. package.json
      4. config/vite.config.js
    3. Adding a Widget
      1. Write the Svelte component
      2. Write the Stimulus controller
      3. Write the entrypoint
    4. Binding a Svelte component
      1. Modify Stimulus manifest
      2. Activating the controller
    5. How the Build Works
    6. Development
    7. Why Not turbo-mount?

How Raft uses Javascript.

Most of the time, Raft uses importmaps to load stimulus controllers.

However, some components use Svelte. There is a parallel path for these that uses Vite to build the components and load them via Stimulus.

Importmaps for engines

To make the app load the engine’s javascript source files via importmap:

require "importmap-rails"

module MyEngine
  class Engine < ::Rails::Engine
    isolate_namespace MyEngine

    initializer "my_engine.importmap", before: "importmap" do |app|
      app.config.importmap.paths ||= []
      app.config.importmap.paths << root.join("config/importmap.rb")
      app.config.importmap.sweep_cache = false
    end

    initializer "my_engine.assets" do |app|
      app.config.assets.paths << root.join("app/assets/stylesheets")
      app.config.assets.paths << root.join("app/javascript")
      app.config.assets.precompile += ["my_engine/style.css"]
    end

    config.autoload_paths << root.join("lib")
  end
end

Svelte components

Overview

The approach used here tries to keep two javascript worlds cleanly separated:

Directory Toolchain Purpose
app/javascript/ Importmaps Stimulus controllers, Turbo, existing JS
app/frontend/ Vite Svelte components, Svelte-dependent Stimulus shims

Turbo handles navigation. Stimulus manages component lifecycle (mount/unmount). Svelte handles the UI inside each widget element. None of them step on each other. We use bun in the Vite toolchain as the package manager, but this is optional.

Setup and configuration

Directory Structure

app/frontend/
  entrypoints/           each file here becomes a separate Vite bundle
    counter.js
    datepicker.js
  controllers/           Stimulus shim controllers (imported by entrypoints)
    counter_controller.js
    datepicker_controller.js
  components/            Svelte components
    Counter.svelte
    DatePicker.svelte

app/javascript/          untouched, managed by importmaps as before
  controllers/
    application.js
    ...

vite_ruby

Install vite_ruby:

bundle add vite_ruby

Change config/vite.json to use bun and “app/frontend”:

{
  "all": {
    "sourceCodeDir": "app/frontend",
    "watchAdditionalPaths": [],
    "packageManager": "bun"
  },
  "development": {
    "autoBuild": true,
    "publicOutputDir": "vite-dev",
    "port": 3036
  },
  "test": {
    "autoBuild": true,
    "publicOutputDir": "vite-test",
    "port": 3037
  },
  "production": {
    "publicOutputDir": "vite"
  }
}

Now we can install vite:

bundle exec vite install

package.json

{
  "private": true,
  "type": "module",
  "devDependencies": {
    "vite": "^6.2.6",
    "vite-plugin-ruby": "^5.1.1",
    "vite-plugin-externalize-dependencies": "^1.0.1"
  },
  "dependencies": {
    "@sveltejs/vite-plugin-svelte": "^7.0.0",
    "svelte": "^5.53.11"
  }
}

Install javascript to node_modules (ignored in git):

bun install

config/vite.config.js

RubyPlugin() reads config/vite.json and configures Vite automatically (source directory, output directory, entrypoints, manifest, and dev server settings).

manualChunks part gives the Svelte runtime a stable filename so it can be loaded separately on every page and cached permanently by the browser. It bundles the svelte and svelte/internal libraries into app/public/vite/assets/svelte-runtime-{hash}.js for production, or loaded via Vite dev server for development.

We also need to exclude @hotwired/stimulus from Vite’s build process, so it will load it via normal ESM include, which works because we have an importmap for it.

import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import RubyPlugin from "vite-plugin-ruby";
import externalize from "vite-plugin-externalize-dependencies";

export default defineConfig({
  plugins: [
    RubyPlugin(),
    svelte(),
    externalize({
      externals: ["@hotwired/stimulus"],
    }),
  ],
  build: {
    rollupOptions: {
      external: ["@hotwired/stimulus"],
      output: {
        manualChunks: {
          "svelte-runtime": ["svelte", "svelte/internal"],
        },
      },
    },
  },
});

Adding a Widget

A Svelte component loaded by Vite and mounted using Stimulus requires three files:

  1. The component Svelte file(s)
  2. A Stimulus controller that just calls Svelte.mount
  3. A Vite entrypoint file that will have all it’s dependencies bundled together. It’s job is to register the controller.

Write the Svelte component

app/frontend/components/Counter.svelte:

<script>
  export let start = 0
  export let step = 1
  let count = start
</script>

<div class="counter">
  <button on:click={() => count -= step}></button>
  <span>{count}</span>
  <button on:click={() => count += step}>+</button>
</div>

Write the Stimulus controller

This is the glue between Turbo’s page lifecycle and the Svelte component. Props are passed via a data attribute so the server controls the initial state.

app/frontend/controllers/counter_controller.js:

import { Controller } from '@hotwired/stimulus'
import { mount } from "svelte";
import Counter from '../components/Counter.svelte'

export default class extends Controller {
  static targets = ["element"];
  static values = {
    props: Object
  }

  connect() {
    this.widget = mount(Hello, {
      target: this.elementTarget,
      props: this.propsValue,
    });
  }

  disconnect() {
    this.widget?.$destroy()
  }
}

Note: the controller can use this.element instead of this.elementTarget if the element to be bound to is always the one that the controller is bound to, which is usually the case.

Write the entrypoint

The entrypoint is a thin shim that registers the controller with Stimulus. Application.getOrStart() attaches to the same Stimulus instance that importmaps already started. Alternately, you can register with window.Stimulus if you set it previously. For example:

app/javascript/controllers/application.js:

import { Application, defaultSchema } from "@hotwired/stimulus";
const application = Application.start(document.documentElement, customSchema);
window.Stimulus = application;
export { application };

app/frontend/entrypoints/counter.js:

import CounterController from '../controllers/counter_controller.js'
window.Stimulus.register('counter', CounterController)

Binding a Svelte component

Modify Stimulus manifest

The normal Stimulus manifest will search the DOM for data-controller properties and load those controllers. In our setup, some controllers must not be loaded this way and should be loaded by Vite entrypoints instead. Here is a hackish manifest for Stimulus that will ignore elements with data-controller if data-loader is set to “vite”.

app/javascript/controllers/index.js:

import { application } from "controllers/application";
import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading";

const originalQueryAll = document.querySelectorAll;
document.querySelectorAll = function (selector) {
  const results = originalQueryAll.call(this, selector);
  if (selector.includes("[data-controller]")) {
    return Array.from(results).filter((el) => {
      return el.dataset["loader"] != "vite";
    });
  }
  return results;
};
lazyLoadControllersFrom("controllers", application);
document.querySelectorAll = originalQueryAll;

Activating the controller

Use vite_javascript_tag to load a vite entrypoint (including the bundled Stimulus controller + Svelte components) on the pages you use the Svelte component.

Markup to load the svelte-runtime script is automatically included when we do vite_javascript_tag because
vite knows that counter entrypoint depends on it.

<% content_for :head do %>
  <%= vite_client_tag # only used in dev mode %>
  <%= vite_javascript_tag 'counter' %>
<% end %>

Next, bind the controller to an element:

<div
  data-controller="counter"
  data-counter-target="element"
  data-loader="vite"
  data-counter-props-value="<%= { start: 5, step: 1 }.to_json %>"
></div>

How the Build Works

Vite treats every file in app/frontend/entrypoints/ as a named bundle entry.

Because multiple entries share Svelte’s runtime, Rollup splits it out into the svelte-runtime chunk automatically. manualChunks just ensures this chunk gets a stable, predictable filename rather than a Rollup-generated hash.

Build output:

public/vite/assets/
  svelte-runtime-[hash].js      <- ~10KB, loaded on every page
  counter-[hash].js             <- tiny, only pages that need it
  datepicker-[hash].js

vite_javascript_tag resolves the content-hashed filename automatically via the Vite manifest, so you always reference the logical name (counter) rather than the hashed one (like how Propshaft works for normal Rails assets).

Development

In content security policy, allow websocket connection to vite dev server:

    if Rails.env.development?
      policy.connect_src :self, "ws://localhost:3036"
    end

Start both servers:

bin/rails server
bin/vite dev

vite_ruby with autoBuild: true will also build on demand if the Vite dev server is not running, which is convenient in environments where you only want one process.

Why Not turbo-mount?

turbo-mount is a good option when you want zero boilerplate – it generates the Stimulus controller automatically. The tradeoff is that it bundles all components into a single entry point, so you cannot do per-page loading. This setup trades a little boilerplate (one entrypoint file and one controller file per widget) for the ability to load only what each page needs.