rails-hotwire
Skill1 starsChanged 19 days agoMITView on GitHub
Build and fix interactive Rails UI with Hotwire - Turbo Drive, Frames, Streams, broadcasts, page refreshes and morphing, and Stimulus. Use before writing a Stimulus controller, a Turbo Frame or Stream, a broadcast, or a form response in a Rails app with turbo-rails or stimulus-rails; when Turbo navigation, a frame, a stream, or a form misbehaves; or to audit or upgrade from rails-ujs, Turbolinks, or Turbo 7.
In this repository11 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- captureSkill
- project-logSkill
- project-memorySkill
- rails-contextSkill
- rails-design-systemSkill
- rails-generatorsSkill
- rails-minitestSkill
- rails-view-componentSkill
- toolsmithSkill
The file
We split this skill into the rules it gives an agent
11 rules. About 0 are common, 1 is distinctive, and 10 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
10
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 35
Read Gemfile.lock: turbo-rails (2.x ships Turbo 8), stimulus-rails, importmap-rails or jsbundling-rails or vite_rails, requestjs-rails, …
- Very specific: line 36
Read the layouts: turbo_refreshes_with (morph refreshes), view-transition and prefetch meta tags, yield :head, where the JavaScript loads.
- Very specific: line 37
Read app/javascript/AGENTS.md and app/views/AGENTS.md if they exist, app/javascript/controllers/index.js, and two or three existing …
- Very specific: line 38
Look for the app's own Hotwire helpers (app/helpers/*turbo*, *stream*, a flash stream helper, form helpers that add Stimulus attributes) …
- Very specific: line 48
Every interaction works as a full-page request first: the same URL renders the whole page, and the frame or stream only narrows what …
- Very specific: line 59
(hard): Every frame id and stream target is derived from records - turbo_frame_tag record, :purpose, dom_id(record, :purpose) - or is a …
- Distinctive: line 88
Verify in a browser: Check the change in a real browser when the app can run: the happy path, the failed-validation path, the back button, …
- Very specific: line 97
If the app has rails-view-component, also run its audit for the component side.
- Very specific: line 98
Run the integration and system tests once for the baseline, delegated to rails-minitest:minitest-runner when that agent is available.
- Very specific: line 115
Run the full suite (bin/rails test:all) and the audit again.
- Very specific: line 117
If the app has app/javascript/AGENTS.md or app/views/AGENTS.md, update them with the conventions this run set up.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Rails Hotwire
You build Rails UI the Hotwire way: the server renders HTML, Turbo moves it to the browser, and Stimulus adds only the behaviour HTML cannot express. Pages work as plain HTML first; every layer on top is an enhancement. This file holds the rules for every change. The references/ files beside it hold the detail - they ship with this skill, so resolve their paths from this skill's directory (${CLAUDE_SKILL_DIR}), not the app. Read the matching one before you write that kind of code:
| Read | Before |
references/drive.md | links, navigation, redirects, the page cache, prefetch, the progress bar, <head> and assets, view transitions, turning Drive off |
references/frames.md | adding a Turbo Frame, lazy loading, targeting _top or another frame, fixing "Content missing" |
references/streams.md | a Turbo Stream response, a stream template, a custom stream action, flash messages after a stream |
references/broadcasting.md | pushing updates over Action Cable: refresh or targeted broadcasts, stream names, authorisation, adapters |
references/morphing.md | page refreshes, method: :morph, or anything that must keep its state through a morph |
references/stimulus.md | writing or changing a Stimulus controller, or loading controllers |
references/forms.md | a form that submits through Turbo: statuses, errors, submit buttons, confirm, autosubmit, autosave, dependent fields |
references/patterns.md | dialogs, inline editing, search and filters, pagination and infinite scroll, tabs, flash, optimistic UI, sorting, live counters, and the accessibility of dynamic updates |
references/testing.md | testing stream responses, frames, broadcasts, morphs, or Stimulus behaviour |
references/native.md | an app that ships, or will ship, Hotwire Native iOS or Android shells |
references/upgrade.md | an app on rails-ujs, Turbolinks, Turbo 7, or Stimulus 1.x syntax, or using removed Turbo APIs |
For components: when the UI is a ViewComponent, the rails-view-component skill owns rendering components in streams and broadcasts, component Stimulus identifiers, and TurboViewComponent. The rules here still apply. Visual design belongs to the app's design system.
Two modes
- Writing mode - the Hotwire parts of a change, or a fix. Follow the rules below. Changes that belong to the task need no approval step.
- App mode - the user asks to audit, upgrade, or reorganise the app's Hotwire setup. Follow "App mode" at the end: survey, plan, approval, apply, verify.
In the main conversation, when subagents are available, hand Hotwire work to rails-hotwire:hotwire-builder (it has this skill loaded) and pass it the change's intent, the pages and records involved, and who should see live updates. Do a one-line fix yourself. In app mode the agent returns the Phase 1 plan; get the user's approval, then call it again with the approved plan.
Detect first
- Read
Gemfile.lock:turbo-rails(2.x ships Turbo 8),stimulus-rails,importmap-railsorjsbundling-railsorvite_rails,requestjs-rails,view_component, and the cable adapter inconfig/cable.yml(production). Below turbo-rails 2.0, or with rails-ujs or Turbolinks loaded, readreferences/upgrade.mdfirst. - Read the layouts:
turbo_refreshes_with(morph refreshes), view-transition and prefetch meta tags,yield :head, where the JavaScript loads. - Read
app/javascript/AGENTS.mdandapp/views/AGENTS.mdif they exist,app/javascript/controllers/index.js, and two or three existing controllers and stream templates, for the app's idiom. - Look for the app's own Hotwire helpers (
app/helpers/*turbo*,*stream*, a flash stream helper, form helpers that add Stimulus attributes) and use them.
The app's settled conventions win on style: naming, where streams live, how flash is shown, how state is styled. The rules marked hard below win over the app. In writing mode, follow them in new code and report the old violations you saw; fix old ones only in app mode.
Rules for every change
Choosing the tool
- Use the lightest tool that does the job, in this order: a plain link or form under Turbo Drive (with a redirect back, which morphs when refreshes morph); a Turbo Frame for navigation scoped to one part of the page or for lazy loading; a Turbo Stream response for several targeted changes from one request; a broadcast for changes other people must see. Add Stimulus only for behaviour HTML cannot express - and never custom JavaScript outside a Stimulus controller.
- (hard) The server renders all UI as HTML. JavaScript never builds markup from JSON. A controller that needs data fetches a rendered fragment (a frame, a stream, or an HTML partial).
- Every interaction works as a full-page request first: the same URL renders the whole page, and the frame or stream only narrows what changes. Keep a
format.htmlpath next to everyformat.turbo_stream.
Drive and forms
- (hard) Anything that changes state is a non-GET request:
button_toor a form, never a plain link. Turbo 8 prefetches GET links on hover, so a state-changing GET link runs when a user points at it. - (hard) A failed form submission renders with a 4xx status (
status: :unprocessable_content;:unprocessable_entitybefore Rack 3.1); a successful one redirects (status: :see_otherfor update and destroy, as the Rails scaffold does) or answers with a stream. Turbo shows nothing for a 200 response to a form submission. - Pages survive the page cache and back button: flashes and one-off notices carry
data-turbo-temporary; open menus, dialogs, and widgets close or tear down onturbo:before-cache; nothing assumes a page loads once (noDOMContentLoaded).
Frames and streams
- A Turbo Frame exists for navigation inside it or for lazy loading. (hard) Never use a frame only as a stream or broadcast target: a frame captures every link and form inside it. A stream target is any element with an id.
- (hard) Every frame id and stream target is derived from records -
turbo_frame_tag record, :purpose,dom_id(record, :purpose)- or is a fixed name for a page singleton (:flash,:notifications). Never random, never built by hand from ids. Ids are unique on the page. - Stream responses with more than one or two actions live in an
action.turbo_stream.erbtemplate; render partials into streams instead of repeating markup.
Broadcasts
- Prefer a refresh broadcast (
broadcasts_refreshes, withtouch: trueon children) for pages that show the same records to many people: each browser re-renders its own page with its own session. Use targeted broadcasts for fragments that look the same to everyone - chat lines, feeds, counters - or for high-frequency updates. - (hard) Broadcast markup is request-free and viewer-independent: no
current_user,Current,request,params, or session in anything a broadcast renders. Personalise on the client (a meta tag with the viewer id plus CSS or a controller) or use a refresh broadcast. - (hard) Broadcast after commit and render in a job:
_latermethods (broadcast_replace_later_to), fromafter_*_commitcallbacks or thebroadcasts_*macros. Onlyremovestays synchronous - a job cannot load a deleted record. - (hard) A stream that carries private data is scoped to who may see it (
[Current.account, :invoices],[user, :notifications]) and, when access can be lost, checked at subscription in a custom channel. A signed stream name proves the server issued it; it never expires and is not bound to a user.
Morphing
- On pages that morph (refreshes,
method: :morph,refresh: :morphframes), protect what the user is doing: cancelturbo:before-morph-attributefor an open<dialog>or<details>, cancelturbo:before-morph-elementfor a control with an unsubmitted edit, and rebuild JavaScript-owned DOM onturbo:morph. Reservedata-turbo-permanentfor elements whose content never changes (a player, a third-party widget), with a stable unique id - never use it to keep state.
Stimulus
- A controller is small, named after a behaviour (
toggle,auto-submit,clipboard), configured from HTML with values, targets, classes, and params, and reused across pages. Logic that is the same in several controllers goes in a plain module inapp/javascript/helpers/. - State lives in the DOM - values,
hidden,aria-*,data-*attributes - so the server, the cache, and morphs can all restore it. React to a changed value in<name>ValueChanged, not only inconnect. - (hard) A controller undoes in
disconnect()everything it set up: listeners onwindowordocument(or declare them as@window/@documentactions), timers, observers, third-party instances. It reaches only its own element, targets, and outlets - neverdocument.querySelectorwith a fixed selector. - Controllers talk through dispatched events (
this.dispatch("changed")plus adata-action), or outlets when one controller really drives another.
Accessibility
- Use native elements first:
<button>for actions,<a>for navigation,<dialog>for modals,<details>for disclosure. Keeparia-expanded,aria-pressed, andaria-busyin step with state. After a frame or stream changes what the user was working on, put focus where they expect it; announce changes that matter in anaria-liveregion.
Tests
- Test each layer where it is cheapest: stream and frame responses in integration tests (
as: :turbo_stream,assert_turbo_stream), broadcasts in model tests that run the jobs (include ActiveJob::TestHelper,perform_enqueued_jobs,assert_turbo_stream_broadcastswith the expected count, including zero for people who must not get it), and only what needs a browser - Stimulus behaviour, morphs that keep state, live updates - in system tests. The rails-minitest skill's rules apply to all of them when it is installed.
Verify in a browser. Check the change in a real browser when the app can run: the happy path, the failed-validation path, the back button, and - for live updates - a second window. After a Turbo navigation, wait for the new page before you look.
App mode
The approval gate is a hard stop. Change nothing until the user approves the Phase 1 plan in a reply to that plan ("approve", "yes", "go", or edits to it). A reply about something else is not approval.
Phase 1: survey and plan
- From the app root, run
ruby "${CLAUDE_SKILL_DIR}/scripts/audit_hotwire". It reports the stack, the layouts' Turbo setup, an inventory (controllers, stream templates, frames, broadcasts, subscriptions, system tests), and findings with file and line. Its header comment explains each rule. Its findings are leads: confirm each one in the code before it goes into the plan. - If the app has
rails-view-component, also run its audit for the component side. - Run the integration and system tests once for the baseline, delegated to
rails-minitest:minitest-runnerwhen that agent is available. Present the plan, grouped, each item with its files and a one-line reason:
- Upgrade: rails-ujs or Turbolinks removal, Turbo 7 to 8, removed APIs, Stimulus 1.x syntax (
references/upgrade.md). - Correctness: form statuses, state-changing GET links, sync broadcasts, request state in broadcast partials, unauthorised private streams.
- Structure: frames used only as targets, inline streams to move into templates, duplicated controllers to merge into one generic controller, helpers to extract.
- Morphing: pages to switch to morph refreshes, and the state each one must protect.
- Stimulus hygiene: leaks, global queries, unregistered controllers.
- Tests: behaviour with no test at the right level.
Say what you will not change and why. Then wait for approval.
- Upgrade: rails-ujs or Turbolinks removal, Turbo 7 to 8, removed APIs, Stimulus 1.x syntax (
Phase 2: apply
Work in this order: upgrade, correctness, structure, morphing, Stimulus hygiene, tests. Gem and package changes go through bundle or bin/importmap, and you confirm the lock or pin file changed. Run the affected tests after each group, and check each changed interaction in a browser.
Phase 3: verify and report
- Run the full suite (
bin/rails test:all) and the audit again. Both must be no worse than the baseline. - Walk the changed pages in a browser: navigation, back button, forms (success and failure), live updates in two windows.
- If the app has
app/javascript/AGENTS.mdorapp/views/AGENTS.md, update them with the conventions this run set up. - Report what changed, the findings before and after, and what is left. Commit only when the user or the project's rules say so.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

