Skip to content

Commit e5c1d9f

Browse files
planetaskaclaude
andcommitted
hibiki_rails: install registers the client via a file-backed shim
stimulus:manifest:update rewrites controllers/index.js wholesale from the *_controller.js files, so the unmanaged import/register lines the install generator appended were dropped on every manifest run. Install now creates app/javascript/controllers/hibiki_controller.js re-exporting the packaged controller: the manifest re-derives the exact "hibiki" registration from the filename, importmap apps eager-load it (index.js untouched), and jsbundling apps get the manifest-format pair appended. A hint flags pre-shim direct registrations that would now double up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fa42e93 commit e5c1d9f

5 files changed

Lines changed: 96 additions & 19 deletions

File tree

hibiki_rails/README.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,20 +104,24 @@ import map, so importmap-rails apps have no install step beyond
104104
registering the controller — `bin/rails g hibiki:rails:install` does it
105105
(plus the `Helpers` include below, the `ApplicationCable` boilerplate,
106106
and the `@rails/actioncable` pin — a stock app has neither until its
107-
first `rails g channel`), or add the line yourself:
107+
first `rails g channel`), or create the one-line shim yourself:
108108

109109
```js
110-
// app/javascript/controllers/index.js
111-
import HibikiController from "hibiki-rails"
112-
application.register("hibiki", HibikiController) // identifier must be "hibiki"
110+
// app/javascript/controllers/hibiki_controller.js
111+
export { default } from "hibiki-rails" // registers as "hibiki" — the helpers hardcode that identifier
113112
```
114113

114+
The registration is a file-backed shim on purpose: importmap apps
115+
eager-load it from the controllers directory, jsbundling apps get the
116+
matching import/register pair in `controllers/index.js` (the install
117+
generator appends it), and because it is derived from a real controller
118+
file, `bin/rails stimulus:manifest:update` regenerates it instead of
119+
dropping it.
120+
115121
(jsbundling/vite apps: `npm install hibiki-rails` — the [npm
116122
package](https://www.npmjs.com/package/hibiki-rails) is the same module the
117123
engine vendors, and pulls in `@rails/actioncable`; release in lockstep with
118-
the gem. One caveat: `bin/rails stimulus:manifest:update` regenerates
119-
`controllers/index.js` from the controller files and drops the register
120-
line above — re-run `hibiki:rails:install` afterwards, it's idempotent.)
124+
the gem.)
121125

122126
The client is one generic Stimulus controller that drives any *island*: a
123127
DOM subtree bound to one channel subscription. Islands are stamped with

hibiki_rails/lib/generators/hibiki/rails/generator_helpers.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ module Generators
1111
# destination_root, so the install generator uses them too.
1212
module GeneratorHelpers
1313
INDEX_JS = "app/javascript/controllers/index.js"
14+
SHIM = "app/javascript/controllers/hibiki_controller.js"
1415
APPLICATION_HELPER = "app/helpers/application_helper.rb"
1516
REGISTER_FRAGMENT = 'application.register("hibiki"'
1617
IMPORTMAP = "config/importmap.rb"
@@ -34,7 +35,9 @@ def nested? = !regular_class_path.empty?
3435

3536
def name_parts = regular_class_path + [file_name]
3637

37-
def hibiki_registered? = wired?(INDEX_JS, REGISTER_FRAGMENT)
38+
# The install generator's file-backed shim is the wiring artifact;
39+
# the index.js fragment covers hand-wired (or pre-shim) apps.
40+
def hibiki_registered? = exists?(SHIM) || wired?(INDEX_JS, REGISTER_FRAGMENT)
3841

3942
def helpers_included? = wired?(APPLICATION_HELPER, "Hibiki::Rails::Helpers")
4043

hibiki_rails/lib/generators/hibiki/rails/install/install_generator.rb

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ module Rails
88
module Generators
99
# Wiring only — the client itself stays vendored in the engine (the
1010
# data-hibiki-* attribute names are a private Ruby↔JS contract that
11-
# versions inside this gem, so nothing is ever copied into the app).
12-
# Every action is idempotent by content check, so rerunning is safe.
11+
# versions inside this gem, so the client is never copied into the
12+
# app; the shim it creates only re-exports it). Every action is
13+
# idempotent by content check, so rerunning is safe.
1314
class InstallGenerator < ::Rails::Generators::Base
1415
include GeneratorHelpers
1516

@@ -19,12 +20,11 @@ class InstallGenerator < ::Rails::Generators::Base
1920
"controller, includes Hibiki::Rails::Helpers in ApplicationHelper, " \
2021
"creates the ApplicationCable boilerplate, and pins @rails/actioncable."
2122

23+
# Exactly the lines `stimulus:manifest:update` emits for the shim,
24+
# so a later manifest run converges instead of duplicating.
2225
REGISTER = <<~JS
2326
24-
// The packaged hibiki client (the "hibiki-rails" module: the engine's
25-
// importmap pin, or the npm package in bundler apps). The identifier
26-
// must be "hibiki" — the Ruby helpers hardcode it in data-controller.
27-
import HibikiController from "hibiki-rails"
27+
import HibikiController from "./hibiki_controller"
2828
application.register("hibiki", HibikiController)
2929
JS
3030

@@ -46,8 +46,18 @@ class InstallGenerator < ::Rails::Generators::Base
4646
pin "@rails/actioncable", to: "actioncable.esm.js"
4747
RUBY
4848

49+
# The registration lives in a file-backed shim so that
50+
# `stimulus:manifest:update` (which rewrites index.js wholesale from
51+
# the *_controller.js files) re-derives it instead of dropping it.
52+
def create_shim_controller
53+
template "hibiki_controller.js.tt", SHIM
54+
end
55+
56+
# Importmap apps eager-load the shim from the controllers directory;
57+
# a manifest-style index.js (jsbundling) must name it explicitly.
4958
def register_controller
50-
return say_status :identical, INDEX_JS, :blue if hibiki_registered?
59+
return legacy_registration_hint if importmap?
60+
return say_status :identical, INDEX_JS, :blue if wired?(INDEX_JS, REGISTER_FRAGMENT)
5161
return manual_wiring(INDEX_JS, REGISTER) unless exists?(INDEX_JS)
5262

5363
append_to_file INDEX_JS, REGISTER
@@ -80,6 +90,17 @@ def pin_actioncable
8090

8191
private
8292

93+
# Installs that predate the shim registered straight in index.js;
94+
# left in place next to the eager-loaded shim that would register
95+
# "hibiki" twice.
96+
def legacy_registration_hint
97+
return unless wired?(INDEX_JS, REGISTER_FRAGMENT)
98+
99+
say_status :hint, "#{INDEX_JS} still registers \"hibiki\" directly — " \
100+
"remove those lines; the eager-loaded " \
101+
"hibiki_controller.js shim replaces them", :yellow
102+
end
103+
83104
def bundler_note
84105
say_status :skip, "#{IMPORTMAP} not found — with a JS bundler, " \
85106
"npm/yarn add hibiki-rails instead (it pulls in " \
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Registers the packaged hibiki client (the "hibiki-rails" module: the
2+
// engine's importmap pin, or the npm package in bundler apps) as the
3+
// "hibiki" Stimulus controller — the Ruby helpers hardcode that
4+
// identifier in data-controller. File-backed on purpose: `bin/rails
5+
// stimulus:manifest:update` re-derives this exact registration from the
6+
// filename, so it survives manifest rewrites.
7+
export { default } from "hibiki-rails"

hibiki_rails/spec/generators/install_generator_spec.rb

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
end
1515

1616
let(:index_js) { File.join(@destination, "app/javascript/controllers/index.js") }
17+
let(:shim) { File.join(@destination, "app/javascript/controllers/hibiki_controller.js") }
1718
let(:application_helper) { File.join(@destination, "app/helpers/application_helper.rb") }
1819
let(:importmap) { File.join(@destination, "config/importmap.rb") }
1920
let(:cable_channel) { File.join(@destination, "app/channels/application_cable/channel.rb") }
@@ -38,16 +39,47 @@ def seed_stock_app
3839
RUBY
3940
end
4041

41-
it "registers the packaged controller and includes the helpers" do
42+
it "creates the file-backed shim and includes the helpers" do
4243
seed_stock_app
44+
original_index = File.read(index_js)
4345
run_generator(described_class, destination: @destination)
4446

45-
expect(File.read(index_js)).to include('import HibikiController from "hibiki-rails"')
46-
.and include('application.register("hibiki", HibikiController)')
47+
expect(File.read(shim)).to include('export { default } from "hibiki-rails"')
48+
# Importmap apps eager-load the shim — index.js stays untouched, so
49+
# stimulus:manifest:update has nothing of ours to drop.
50+
expect(File.read(index_js)).to eq(original_index)
4751
expect(File.read(application_helper))
4852
.to include("module ApplicationHelper\n include Hibiki::Rails::Helpers\nend")
4953
end
5054

55+
it "registers the shim in a manifest-style index.js (jsbundling apps)" do
56+
seed_stock_app
57+
File.delete(importmap)
58+
File.write(index_js, %(import { application } from "./application"\n))
59+
run_generator(described_class, destination: @destination)
60+
61+
# Exactly the lines stimulus:manifest:update emits for the shim file,
62+
# so later manifest runs converge instead of dropping the registration.
63+
expect(File.read(index_js)).to include('import HibikiController from "./hibiki_controller"')
64+
.and include('application.register("hibiki", HibikiController)')
65+
expect(File.read(shim)).to include('export { default } from "hibiki-rails"')
66+
end
67+
68+
it "hints when a pre-shim registration is still in an importmap index.js" do
69+
seed_stock_app
70+
File.write(index_js, <<~JS)
71+
import { application } from "controllers/application"
72+
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
73+
eagerLoadControllersFrom("controllers", application)
74+
import HibikiController from "hibiki-rails"
75+
application.register("hibiki", HibikiController)
76+
JS
77+
output = run_generator(described_class, destination: @destination)
78+
79+
expect(output).to include("remove those lines")
80+
expect(File.exist?(shim)).to be(true)
81+
end
82+
5183
it "creates the ApplicationCable boilerplate and pins @rails/actioncable" do
5284
seed_stock_app
5385
run_generator(described_class, destination: @destination)
@@ -64,11 +96,20 @@ def seed_stock_app
6496
output = run_generator(described_class, destination: @destination)
6597

6698
expect(output).to include("identical")
67-
expect(File.read(index_js).scan('application.register("hibiki"').count).to eq(1)
6899
expect(File.read(application_helper).scan("Hibiki::Rails::Helpers").count).to eq(1)
69100
expect(File.read(importmap).scan("@rails/actioncable").count).to eq(1)
70101
end
71102

103+
it "is idempotent in jsbundling apps: the registration is appended once" do
104+
seed_stock_app
105+
File.delete(importmap)
106+
File.write(index_js, %(import { application } from "./application"\n))
107+
run_generator(described_class, destination: @destination)
108+
run_generator(described_class, destination: @destination)
109+
110+
expect(File.read(index_js).scan('application.register("hibiki"').count).to eq(1)
111+
end
112+
72113
it "never touches an existing ApplicationCable" do
73114
seed_stock_app
74115
FileUtils.mkdir_p(File.dirname(cable_channel))
@@ -93,6 +134,7 @@ class Channel < ActionCable::Channel::Base
93134
expect(output).to include('application.register("hibiki", HibikiController)')
94135
expect(output).to include("include Hibiki::Rails::Helpers")
95136
expect(File.exist?(index_js)).to be(false)
137+
expect(File.exist?(shim)).to be(true)
96138
end
97139

98140
it "points bundler apps at the npm package instead of pinning" do

0 commit comments

Comments
 (0)