Javascript
- Javascript
- Importmaps for engines
- Svelte components
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:
- The component Svelte file(s)
- A Stimulus controller that just calls Svelte.mount
- 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.