LaunchKit
← All posts
· 14 min read · by The LaunchKit team · 0 views

Ruby on Rails and Vue

"Ruby on Rails and Vue" is three different decisions wearing one phrase, and the one you pick decides whether you ever write a .vue file, whether you run a second process in development, and whether Rails keeps owning the URL. I built all three on this laptop today: Vue through importmap with no build step, Vue through Vite with real single file components, and Vue through Inertia with no JSON API. Everything below came out of those three applications, and the fenced blocks are copied from what ran.

Three scratch apps, all generated by rails new on Rails 8.1.4, Ruby 4.0.5, PostgreSQL on port 15432, macOS on an Apple M2 Max. Vue is 3.5.43 in all three. Browser measurements are headless Chrome 153.0.8010.53 over loopback. The test counts at the bottom are real runs.

Route one: importmap, and it does work

The first thing worth knowing is that the no-build route is not a hack. importmap-rails 2.2.3 pins Vue like anything else:

$ bin/importmap pin vue
Pinning "vue" to vendor/javascript/vue.js via download from https://ga.jspm.io/npm:vue@3.5.43/dist/vue.esm-browser.prod.js

Read the filename. vue.esm-browser.prod.js is the browser build that includes the template compiler, it arrives as one file with no imports of its own, and it is 173,347 bytes on disk. That choice was made for you by the JSPM resolution behind bin/importmap, and it is the right one, because without a build step there is nothing to precompile your templates.

A component then looks like this, in app/javascript/vue/counter.js, pinned with pin_all_from "app/javascript/vue", under: "vue":

import { createApp, ref, onMounted, onUnmounted } from "vue"

export const Counter = {
  setup() {
    const count = ref(0)
    onMounted(() => window.vueAppsAlive++)
    onUnmounted(() => window.vueAppsAlive--)
    return { count, bump: () => count.value++ }
  },
  template: `<p>count is {{ count }}</p><button id="bump" @click="bump">+1</button>`
}

That renders, counts and responds to clicks, with no npm, no package.json and no second process. For an island or two on an otherwise server-rendered application, that is the whole story.

The cost is the compiler. Vue publishes a runtime-only build for exactly this reason, and I pinned it beside the full one to see what happens:

pin "vue" # @3.5.43
pin "vue-runtime", to: "vue-runtime.js" # @3.5.43 runtime-only

vue.runtime.esm-browser.prod.js is 111,539 bytes, 41,894 gzipped, against 173,347 and 62,928 for the full one. So the template compiler is 61,808 bytes of the payload, sent to every visitor, to do at page load what a build step would have done once.

Every gzipped figure on this page is gzip -c at its default level, which is -6. At -9 those same two files are 62,828 and 41,847, so compare like with like if you check them.

The dead end: the runtime build fails silently

I expected the swap to fail loudly. The warning exists: vue.runtime.esm-browser.js in node_modules carries the string Component provided template option but runtime compilation is not supported in this build of Vue.. The .prod.js next to it, which is the one bin/importmap pin downloads, contains that string zero times.

window.__runtimeOnlyThrew = null
try {
  createApp({ template: `<p>compiled in the browser</p>` }).mount(el)
} catch (e) {
  window.__runtimeOnlyThrew = e.message
}
test "the runtime-only build cannot compile a template string and leaves an empty comment" do
  visit "/runtime-only"
  assert_selector "h1", text: "Runtime only"
  assert_equal "<!---->", page.evaluate_script("document.querySelector('#runtime-only').innerHTML")
  assert_nil page.evaluate_script("window.__runtimeOnlyThrew")
end

Green. createApp returned, mount ran, the placeholder text was cleared, and what it left behind was an empty HTML comment node. Nothing threw, nothing was logged, and the browser showed a blank space where a component used to be. If you ever try to trim the importmap payload by switching to the runtime build, this is what you will be debugging, and the only visible symptom is <!----> in the inspector.

Which settles route one: on importmap you ship the full build, or you ship nothing.

Route two: Vite, and what the build step actually buys

vite_rails 3.11.1 on Vite 8.3.1 with @vitejs/plugin-vue 6.0.9. Three lines in vite.config.ts and the same counter becomes a real single file component:

<script setup>
import { ref, onMounted } from "vue"

const props = defineProps({ start: { type: Number, default: 0 } })
const count = ref(props.start)
</script>

<template>
  <p class="readout">count is {{ count }}</p>
  <button id="bump" @click="count++">+1</button>
</template>

<style scoped>
.readout { font-weight: 700; }
</style>
$ bundle exec vite build
vite v8.3.1 building client environment for production...
✓ 10 modules transformed.
public/vite/assets/application-DXO2rdIx.css   0.04 kB │ gzip:  0.06 kB
public/vite/assets/application-BAGadyV4.js   61.18 kB │ gzip: 24.16 kB │ map: 561.70 kB
✓ built in 61ms

61,189 bytes for the entire application, Vue included, against 173,347 for the importmap copy of Vue alone. The difference is not Vite being clever. It is that the templates were compiled at build time, so the compiler is not in the bundle and the parts of Vue nothing imports are gone.

<style scoped> is the other thing you cannot have without a build. It compiled to an attribute selector:

.readout[data-v-1563882d]{font-weight:700}

and the element carries the matching data-v-1563882d attribute, which a system test can assert on.

The cost is two processes in development, a node_modules you now maintain, and a package.json in a Rails repository. That is a real tax and it is the reason importmap exists. Pay it when you have enough Vue to want components in files.

Vite has opinions about your test environment, and they are wrong twice

The first surprise is that your system tests run against production Vue. vite build defaults to production mode, autoBuild is on for the test environment, and so the bundle your tests exercise has had every development warning compiled out of it.

I proved this on a prop type error that is almost impossible to avoid, because el.dataset values are always strings and Vue props are typed. Under bin/vite dev, Chrome logged:

WARNING [Vue warn]: Invalid prop: type check failed for prop "start". Expected Number with value 41, got String with value "41".

The dev server's copy of Vue is 1,201,938 bytes and contains the string Invalid prop three times. The built test bundle contains it zero times, and the system test that renders the same broken component sees an empty console:

test "the test-environment bundle is production Vue, so no prop warning is reachable" do
  visit root_path
  assert_text "count is 41"
  warnings = page.driver.browser.logs.get(:browser).map(&:message).grep(/Vue warn/)
  assert_equal [], warnings

  bundle = Dir["#{Rails.root}/public/vite-test/assets/*.js"].sole
  assert_equal 0, File.read(bundle).scan("Invalid prop").length
end

So a browser-log assertion in a system test is not a safety net for Vue warnings. It cannot be. The warnings are not in the code under test.

The second surprise cost me twenty minutes. My three passing Vite tests started failing all at once, every one of them with expected to find text "count is 41" in "Home", which is the page with no JavaScript on it at all. The only thing that had changed was that I had left bin/vite dev running in another terminal.

bin/vite dev writes tmp/vite-ruby.json:

{"url":"http://localhost:3036","host":"localhost","port":3036,"https":false,"pid":29264}

One file, at root.join("tmp", DEV_SERVER_META_FILENAME), for every environment. dev_server_running? in vite_ruby 3.11.0 returns !dev_server_meta.nil?, so the test process reads the development server's file and concludes a dev server is up. manifest.rb:124 then reads config.auto_build && !dev_server_running?, skips the build, and emits a tag pointing at /vite-test/entrypoints/application.js, which proxies to port 3037 where nothing is listening. No JavaScript, no error, three failing tests and a passing dev server.

The fix is one key in config/vite.json, and with it the tests pass with the dev server still running:

"test": {
  "autoBuild": true,
  "publicOutputDir": "vite-test",
  "port": 3037,
  "devServerConnectionCheck": true
}

Turbo is what actually breaks Vue in a Rails app

Both routes above sit inside a default Rails application, which means Turbo Drive is intercepting every link. This is the part that has nothing to do with which bundler you chose and everything to do with why your component stops appearing.

The first version everybody writes is this one, and it is wrong:

document.addEventListener("DOMContentLoaded", mountCounter)

DOMContentLoaded fires once per document load. Turbo replaces the <body> without one. Navigate away and back and the server's empty <div id="counter"></div> is all that is left, which Capybara will not even call visible:

Capybara::ElementNotFound: Unable to find visible css "#counter"

turbo:load fires after every Turbo render, and swapping the listener fixes the mount. It does not fix the other two.

Turbo caches what Vue rendered, not what Rails sent. I clicked the button three times and navigated away, reading #counter inside turbo:before-cache:

test "Turbo caches the Vue-rendered DOM, not the empty div the server sent" do
  visit root_path
  assert_text "count is 0"
  3.times { find("#bump").click }
  assert_text "count is 3"
  click_link "Other"
  assert_selector "h1", text: "Other"
  assert_equal '<p>count is 3</p><button id="bump">+1</button>',
               page.evaluate_script("window.cachedSnapshots[0]")
end

That string is what Turbo will paint as the preview the next time the user goes back. A counter at 3 is harmless. A cart total, a form in a half-filled state, or a component showing another account's data is not, and the reader sees it before your turbo:load handler has run.

Mounting without unmounting leaks a whole application per visit. Vue's onMounted and onUnmounted make this countable. Three round trips between two pages, with turbo:load mounting and nothing tearing down:

test "without a turbo:before-cache unmount, every visit leaves a live app behind" do
  visit other_path
  assert_selector "h1", text: "Other"
  3.times do
    click_link "Home"
    assert_text "count is 0"
    click_link "Other"
    assert_selector "h1", text: "Other"
  end
  assert_equal 3, page.evaluate_script("window.vueAppsAlive")
end

Three live applications, each with its own reactive effects, watchers, timers and event listeners, none of them attached to anything on screen. The same test against a page that calls app.unmount() on turbo:before-cache asserts 0 and passes.

So the whole of the Rails-and-Vue integration, for any bundler, is these six lines:

document.addEventListener("turbo:load", mount)
document.addEventListener("turbo:before-cache", unmount)

with mount holding the app instance in a module-level variable and unmount nulling it. Everything else is packaging.

CSRF, and the test that will not tell you

The moment the Vue component posts something, Rails wants a token. A bare fetch:

$ curl -s -o /dev/null -w "%{http_code}\n" -X POST -H "Content-Type: application/json" \
    -d '{"body":"hello"}' http://localhost:3210/notes
422

and in the development log:

ActionController::InvalidAuthenticityToken (Can't verify CSRF token authenticity.):

The fix is the meta tag csrf_meta_tags already puts in the layout:

const token = document.querySelector("meta[name=csrf-token]").content
await fetch("/notes", { method: "POST", headers: { "X-CSRF-Token": token, ... } })

Here is the part worth the section. The system test for the broken version passes:

test "with the generated test config, a token-less fetch from Vue is accepted" do
  visit "/csrf"
  find("#bare").click
  assert_selector "#bare-status", text: "200"
end

200, not 422, because config/environments/test.rb:29 in every generated Rails application says config.action_controller.allow_forgery_protection = false. Your browser test drives the real component, makes the real request, and confirms a behaviour that will not happen in production. The only way to test it is to turn protection back on for the example:

test "with forgery protection on, a bare fetch is 422 and the meta token makes it 200" do
  was = ActionController::Base.allow_forgery_protection
  ActionController::Base.allow_forgery_protection = true
  visit "/csrf"
  find("#bare").click
  assert_selector "#bare-status", text: "422"
  find("#tokened").click
  assert_selector "#tokened-status", text: "200"
ensure
  ActionController::Base.allow_forgery_protection = was
end

Both of those pass, which is the point: the suite is green either way, and only one of the two tests knows anything.

Route three: Inertia, where the API disappears

inertia_rails 3.22.0 is the answer to the question most people are really asking, which is how to use Vue without building and versioning a JSON API for your own frontend. The controller renders a page component by name and hands it props:

class CounterController < InertiaController
  def index
    render inertia: "counter/index", props: { start: 41 }
  end
end

The first response is an ordinary Rails HTML document with the props inlined. Note where they are: inertia_rails/helper.rb:38 branches on config.use_script_element_for_initial_page, and in 3.22.0 the default puts the page object in a script element rather than in a data-page attribute on the div, so a selector written against the attribute finds nothing.

<script data-page="app" type="application/json">{"component":"counter/index","props":{"errors":{},"start":41},"url":"/counter","version":"b9d600f5e4ef1ece9ebfd748bde78563fcf7c458","encryptHistory":true,"clearHistory":false,"sharedProps":["errors"]}</script>
<div id="app"></div>

No fetch, no CORS, no serializer, no route table in two places. Every subsequent navigation through an Inertia <Link> is a request to the same Rails route with X-Inertia: true, and the response is the page object as JSON:

$ curl -s -H "X-Inertia: true" -H "X-Inertia-Version: b9d600f5e4ef1ece9ebfd748bde78563fcf7c458" \
    -D- http://localhost:3212/other | tail -3
content-length: 185

{"component":"other/index","props":{"errors":{}},"url":"/other","version":"b9d600f5e4ef1ece9ebfd748bde78563fcf7c458","encryptHistory":true,"clearHistory":false,"sharedProps":["errors"]}

185 bytes for a page change. Send a stale version, or none, and the protocol forces a full reload rather than letting a new frontend run against old assets:

HTTP/1.1 409 Conflict
x-inertia-location: http://localhost:3212/other
content-length: 0

The price is the bundle and the state. @inertiajs/vue3 plus Vue built to 174,269 bytes, 58,870 gzipped, which is more than either other route, though page components are code split and cost almost nothing each: the counter page is a 437 byte chunk and the other page is 305. And component state does not survive a visit. This test passes:

test "component state is not preserved across an Inertia visit" do
  visit "/counter"
  3.times { find("#bump").click }
  assert_text "count is 44"
  find("#to-other").click
  assert_selector "h1", text: "Other"
  find("#to-counter").click
  assert_text "count is 41"
end

Back to 41, the value the controller sent. Inertia is not a single page application with a client store that happens to talk to Rails. It is Rails navigation with Vue doing the rendering, and the server remains the source of every prop.

What it costs at load, which is less than you would guess

From performance.now() inside onMounted, 15 loads each, headless Chrome on loopback with a warm cache: the importmap page reached its mounted hook at a median of 11.3 ms (min 10.0, max 50.2) and the Vite page at 8.2 ms (min 7.3, max 13.7). The importmap figure is also carrying Turbo and Stimulus, which the Vite app does not load at all.

Three milliseconds. On a loopback with nothing to download, the bundle size difference does not show up, and anyone quoting first-render numbers taken this way is measuring their own laptop. The 112,158 byte difference between the importmap payload and the Vite bundle is a download cost on a real connection, not a parse cost here, and that is the honest shape of it.

The verdict

If Vue is going to render your screens, use Vite and Inertia together. You get single file components, you get the 61 kB class of bundle instead of the 173 kB one, and Inertia removes the API layer that is the actual expensive part of a Rails-plus-Vue application: the second route table, the serializers, the auth story on two sides, and the deploy that has to ship them in step.

If you need Vue for one screen in an otherwise Hotwire application, use importmap, accept the 173,347 bytes, write the turbo:load and turbo:before-cache pair on day one, and do not try to save the compiler.

What would change this: if your screens are forms, tables and lists, none of the above is worth starting. Turbo does those with no JavaScript written by you, and Ruby on Rails and Hotwire is what that costs instead. The point at which Vue starts paying is when a screen holds state the server should not be told about on every keystroke. Count those screens before you add a package.json.

What this page does not cover

Server-side rendering. Inertia supports it, inertia_ssr_head is already in the generated layout, and I did not run a Node SSR process, so nothing here says what a crawler sees on an Inertia page. Vue Router, Pinia and TypeScript, none of which are installed in any of the three apps. Forms: Inertia's useForm and its error handling against Rails validations is the next thing anybody building this will hit, and it is not measured here. Turbo Frames and Turbo Streams containing Vue components, which is a different and worse lifecycle problem than the Drive one above. Asset fingerprinting and assets:precompile in a real deploy, since all three applications ran in development. And no production-mode benchmark of any kind: the only browser timings above are loopback with a warm cache, stated with their conditions because they are not transferable.

Every figure on this page came out of the three scratch applications described at the top. The full run behind it is 17 system examples with 51 assertions across those three apps, all passing.

#rails #hotwire #comparison

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.