hibiki_rails 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8dc32112018a236bbd193028d2fe6a0c0650afd04cf8e6de5f26d84bed7de97a
4
- data.tar.gz: 15fcf11847178df9f355279f7666b064a557ee5ebd0647f24373aeb638fb0131
3
+ metadata.gz: 514349fcd04fc813cc4833144b4355c56958e9055dda33aefbdc547517d73bfd
4
+ data.tar.gz: 1a9729ad926d72f9bdfb1b4411544893f59c3ebb40d2dd552165bed1299218e5
5
5
  SHA512:
6
- metadata.gz: cf0c15e56804599427b2c9ee2e323118ed2dc6decb1862c1765544535313cf49587997375370cfa335db4b2a32cfb8b33dae6f81ffa8870cf73c99af418cc098
7
- data.tar.gz: 7dd41bb7197cc96a93a9b878ab41ee02b33a18830ecdb8f8926193529cc2c890ca6336eaca1c9eb71f0963262251d07be77895dda03cfd954ae4483a7968fdf6
6
+ metadata.gz: eb8e0207e6b1281f7085e7258a9ed189f836b0427ea3172acf3711b118f80f1a0a8fa97474d5f4ff4deaf69ef0bec8d6083f3b09a3ee1178cb836335453f3d76
7
+ data.tar.gz: 1f4c370144b71c7baea0b3960d69d761b562f6590f281b079f1bf9a26173e7d83c415e262924030e02e4d5bd62876eab7b62c2a12b8a145298ea1d60ff2a5480
data/CHANGELOG.md CHANGED
@@ -4,6 +4,61 @@ The gem and the npm package are released in lockstep and share these version
4
4
  numbers — `app/assets/javascripts/hibiki.js` is a single copy served both ways,
5
5
  so importmap and bundler apps always resolve identical client code.
6
6
 
7
+ ## 0.12.0 — 2026-09-06
8
+
9
+ ### Added
10
+
11
+ **`island` — the ERB block helper for the island root.** One call
12
+ generates the cid, stamps the root with `hibiki_island`, and derives the
13
+ channel's `turbo_stream_from` inside it:
14
+
15
+ ```erb
16
+ <%= island CounterChannel do |cid| %>
17
+ ...
18
+ <% end %>
19
+ ```
20
+
21
+ - `cid:` defaults to a fresh UUID; the block receives it either way.
22
+ - The stream source is derived from the channel class
23
+ (`turbo_stream_from channel.channel_name, cid`), so renaming the channel
24
+ cannot leave a hand-typed streamable behind. `transport: :transmit`
25
+ leaves it out: for a channel that transmits, or one that overrides
26
+ `stream_name` and writes its own line in the block. (`:broadcast` is the
27
+ default; the option is a named value rather than a boolean so it cannot
28
+ be mistaken for Turbo's own `data-turbo="false"`.)
29
+ - `params:` passes through; `tag_name:` picks the root element; any other
30
+ keyword lands on it, with a `data:` hash merged beneath the island's own
31
+ keys.
32
+ - Class-only (`constantize` a dynamic name at the call site) and ERB-only
33
+ (it needs ActionView's `capture`). Phlex components keep
34
+ `div(**hibiki_island(...))`, which remains the primitive.
35
+
36
+ Generators are unchanged: their output keeps the explicit three-line form.
37
+ No client change; the npm release is the lockstep bump.
38
+
39
+ ## 0.11.0 — 2026-09-05
40
+
41
+ ### Changed
42
+
43
+ **The client rides turbo-rails' Action Cable consumer.** `hibiki.js` now
44
+ takes its consumer from `cable.getConsumer()` (exported by
45
+ `@hotwired/turbo-rails`) instead of importing `@rails/actioncable` itself.
46
+ Islands and `turbo_stream_from` share ONE websocket where each page used to
47
+ open two, an app's `cable.setConsumer(...)` applies to islands too, and the
48
+ library stops reaching the page twice: bundler apps shed the second copy
49
+ (~14.6 KB; turbo-rails imports `@rails/actioncable/src`, the client imported
50
+ the package root, and a bundler cannot dedupe two specifiers), importmap apps
51
+ stop fetching `actioncable.esm.js` next to the copy `turbo.min.js` already
52
+ carries (~14.8 KB, one request).
53
+
54
+ - npm: the `@rails/actioncable` peer dependency is gone; `@hotwired/turbo-rails
55
+ >= 8.0` is the peer instead (every Rails 8 app has it).
56
+ - `hibiki:rails:install` no longer pins `@rails/actioncable`. An existing pin
57
+ is harmless — nothing imports it — and may be deleted.
58
+ - Subscribing now awaits the consumer once (a microtask after the first
59
+ island); nothing observable changes, actions are still queued until the
60
+ subscription confirms.
61
+
7
62
  ## 0.10.0 — 2026-08-22
8
63
 
9
64
  ### Added
data/README.md CHANGED
@@ -157,7 +157,7 @@ A few things that make a change easier to accept:
157
157
 
158
158
  The gem side needs nothing special: point an app's Gemfile at your clone with `gem "hibiki_rails", path: "../hibiki-rails"`.
159
159
 
160
- The npm side has one trap. `bun link` from an app resolves the linked package's own imports from the **symlink's realpath**, not from the app — so `hibiki.js` looks for `@rails/actioncable` inside your checkout rather than inside the app that linked it, and if the checkout has no `node_modules` the import fails at build time with nothing pointing at the cause.
160
+ The npm side has one trap. `bun link` from an app resolves the linked package's own imports from the **symlink's realpath**, not from the app — so `hibiki.js` looks for `@hotwired/turbo-rails` and `@hotwired/stimulus` inside your checkout rather than inside the app that linked it, and if the checkout has no `node_modules` the import fails at build time with nothing pointing at the cause.
161
161
 
162
162
  ```sh
163
163
  cd hibiki-rails && bun install # before linking, not after
@@ -1,17 +1,16 @@
1
1
  // The packaged client for hibiki_rails. Two shapes, one plumbing:
2
2
  //
3
- // 1. The generic controller (HibikiController, register as "hibiki"):
3
+ // 1. The generic controller (HibikiController, registered as "hibiki"):
4
4
  // drives any island stamped by the Hibiki::Rails::Helpers Ruby
5
- // helpers. Stimulus is the lifecycle host only (connect/disconnect
5
+ // helpers. Stimulus is only the lifecycle host (connect/disconnect
6
6
  // across Turbo navigation, morphs, and dynamic insertion); the wire
7
7
  // protocol is hibiki-owned data attributes, so server-side components
8
- // never write Stimulus vocabulary and a non-Stimulus client can speak
9
- // the same attributes (toy-phlex's vanilla driver is the proof).
10
- // 2. The subclassable base (ChannelController): for apps that prefer the
8
+ // never write Stimulus vocabulary.
9
+ // 2. The subclassable base (ChannelController): for apps that prefer
11
10
  // familiar Stimulus structure (data-controller="counter",
12
11
  // data-action="counter#increment"). The base owns the consumer, the
13
- // subscription lifecycle, and transport handling; plain actions are
14
- // auto-forwarded to the channel, so a subclass only declares a method
12
+ // subscription lifecycle, and transport handling. Plain actions are
13
+ // auto-forwarded to the channel; a subclass declares a method only
15
14
  // when it needs something custom:
16
15
  //
17
16
  // import { ChannelController } from "hibiki-rails"
@@ -27,18 +26,17 @@
27
26
  // Both shapes speak both transports. Transmit: the server's render
28
27
  // effects `transmit({ html: })` fragments that are swapped in by their
29
28
  // root DOM id, and `transmit_value` messages that update every
30
- // data-hibiki-value placeholder; `received` is registered at subscribe
31
- // time — before the
32
- // server runs build_graph — so the effects' first transmits always land
33
- // (the server-rendered initial HTML is only a paint-avoidance
34
- // placeholder). Turbo broadcasts: when the controller's element contains
35
- // its own <turbo-cable-stream-source> (turbo_stream_from), the graph's
36
- // first broadcast is lost unless that stream has already confirmed ITS
37
- // subscription — so connect awaits it (streamConnected) before
38
- // subscribing, and rendering flows back over the Turbo stream while
39
- // `received` stays idle.
29
+ // data-hibiki-value placeholder. `received` is registered at subscribe
30
+ // time, before the server runs build_graph, so the effects' first
31
+ // transmits always land; the server-rendered HTML fills the space only
32
+ // until they do. Turbo broadcasts: when the controller's element
33
+ // contains its own <turbo-cable-stream-source> (turbo_stream_from), the
34
+ // graph's first broadcast is lost unless that stream has already
35
+ // confirmed ITS subscription — so connect awaits it (streamConnected)
36
+ // before subscribing, and rendering flows back over the Turbo stream
37
+ // while `received` stays idle.
40
38
  //
41
- // The attribute contract of the generic controller (private to this gem —
39
+ // The generic controller's attribute contract (private to this gem —
42
40
  // emitted by the Ruby helpers, interpreted here, versioned together):
43
41
  //
44
42
  // island root data-controller="hibiki"
@@ -60,25 +58,24 @@
60
58
  // value sites data-hibiki-value="<name>" reactive-value placeholder;
61
59
  // the server's transmit_value message updates every match
62
60
  //
63
- // The protocol also has a client-written half — the first attributes in it
64
- // that no Ruby helper emits. These are stamped here at runtime and are
65
- // read-only to app code; app CSS is their whole audience:
61
+ // The client writes the protocol's other half at runtime — read-only to
62
+ // app code, addressed to app CSS:
66
63
  //
67
64
  // island root data-hibiki-busy present while an action is in flight
68
65
  // aria-busy="true" the same fact, for assistive tech
69
66
  // data-hibiki-state connecting | ready | offline | stalled
70
67
  // firing control data-hibiki-busy on the control that started it
71
68
  //
72
- // Everything the app wants out of that is a descendant selector —
73
- // `[data-hibiki-busy] .spinner { display: inline-block }` — so per-row and
74
- // per-button feedback needs no server state and no `{#if loading}` branch.
75
- // Content is stale during a round trip, never absent.
69
+ // App CSS reaches all of it with descendant selectors —
70
+ // `[data-hibiki-busy] .spinner { display: inline-block }` — so per-row
71
+ // and per-button feedback needs no server state. Content is stale during
72
+ // a round trip, never absent.
76
73
  //
77
- // The left side of `->` is a hibiki event name, of which DOM events are a
78
- // subset: click, change, input, submit are delegated listeners, and
79
- // `visible` is a pseudo-event backed by an IntersectionObserver (the
80
- // element entering the viewport). Everything that is not "which event"
81
- // is a sibling attribute, so the token grammar never has to grow.
74
+ // The left side of `->` is a hibiki event name: click, change, input,
75
+ // and submit are delegated DOM listeners; `visible` is a pseudo-event
76
+ // backed by an IntersectionObserver (the element entering the viewport).
77
+ // Everything that is not "which event" is a sibling attribute, so the
78
+ // token grammar never has to grow.
82
79
  //
83
80
  // App JS reaching the graph — the ONE public seam. A gesture that needs
84
81
  // script (drag-and-drop, a third-party widget) fires its action through
@@ -96,19 +93,18 @@
96
93
  // import { performOn } from "hibiki-rails"
97
94
  // performOn(element, "nested_move", { path, to })
98
95
  //
99
- // The return value is the whole contract: truthy (the trip's seq) means
100
- // the action was ACCEPTED — sent live, or queued during the initial
101
- // connect window — so a repaint is coming and the caller should leave the
102
- // DOM as the user arranged it (the morph lands as a visual no-op). Falsy
103
- // (undefined) means it was DROPPED — the island is offline, or the socket
104
- // turned out to be dead at send — and the caller owns recovery: revert
105
- // the gesture, or stand back and let the next repaint self-heal. Nothing
106
- // queues across an offline gap on purpose (a reconnect builds a fresh
107
- // server-side graph, so replayed intent would land on state it was not
108
- // formed against). Everything else is NOT a seam: the data-hibiki-*
109
- // attributes are private, and subclassing ChannelController to reach an
110
- // existing island opens a SECOND subscription — a second server-side
111
- // graph nobody paints from.
96
+ // The return value is the whole contract. Truthy (the trip's seq): the
97
+ // action was ACCEPTED — sent live, or queued during the initial connect
98
+ // window — a repaint is coming, so leave the DOM as the user arranged it
99
+ // and let the morph land as a visual no-op. Falsy (undefined): it was
100
+ // DROPPED — the island is offline, or the socket was dead at send — and
101
+ // the caller owns recovery: revert the gesture, or let the next repaint
102
+ // self-heal. Nothing queues across an offline gap, on purpose: a
103
+ // reconnect builds a fresh server-side graph, so replayed intent would
104
+ // land on state it was not formed against. Everything else is NOT a
105
+ // seam: the data-hibiki-* attributes are private, and subclassing
106
+ // ChannelController to reach an existing island opens a SECOND
107
+ // subscription — a second server-side graph nobody paints from.
112
108
  //
113
109
  // Register the generic controller under the identifier "hibiki" (the
114
110
  // helpers hardcode it):
@@ -116,11 +112,16 @@
116
112
  // import HibikiController from "hibiki-rails"
117
113
  // application.register("hibiki", HibikiController)
118
114
  import { Controller } from "@hotwired/stimulus"
119
- import { createConsumer } from "@rails/actioncable"
120
-
121
- // One consumer shared by every controller — ActionCable multiplexes
122
- // subscriptions over a single websocket. Never disconnected: islands come
123
- // and go with the DOM, the socket stays.
115
+ import { cable } from "@hotwired/turbo-rails"
116
+
117
+ // One consumer shared by every controller — and with Turbo Streams: it is
118
+ // turbo-rails' own (`cable.getConsumer()`), so islands and
119
+ // `turbo_stream_from` multiplex over ONE websocket, and an app's
120
+ // `cable.setConsumer(...)` applies to both. Never disconnected: islands
121
+ // come and go with the DOM, the socket stays. Importing turbo-rails'
122
+ // consumer rather than @rails/actioncable also keeps the library out of
123
+ // the page twice (turbo-rails bundles it by a subpath specifier that no
124
+ // importmap can serve).
124
125
  let consumer
125
126
 
126
127
  // Live islands by root element, for performOn's ancestor walk — membership
@@ -165,12 +166,12 @@ const formPayload = (form) => {
165
166
  }
166
167
 
167
168
  // Before a fallback form goes native, re-stamp its CSRF token from the
168
- // page's csrf-token meta — which is first-paint fresh and session-valid.
169
- // Server-side repaints render without a session (ApplicationController
170
- // .render has none), so a repainted form embeds a stale token or none at
171
- // all; and this only ever runs with scripts alive, which is exactly when
172
- // the DOM may have been repainted. A truly script-free page still holds
173
- // its first-paint token and never needed the help.
169
+ // page's csrf-token meta tag, which is first-paint fresh and
170
+ // session-valid. Server-side repaints render without a session
171
+ // (ApplicationController.render has none), so a repainted form embeds a
172
+ // stale token or none at all. This only runs with scripts alive —
173
+ // exactly when the DOM may have been repainted; a script-free page
174
+ // still holds its first-paint token.
174
175
  const freshenToken = (control) => {
175
176
  if (!(control instanceof HTMLFormElement)) return
176
177
  const meta = document.querySelector('meta[name="csrf-token"]')
@@ -191,21 +192,17 @@ export class ChannelController extends Controller {
191
192
  static values = { cid: String }
192
193
 
193
194
  // Transport-state timings. Class properties on purpose: not Stimulus
194
- // values and not helper options, so the Ruby surface stays unchanged and
195
- // an island stamps nothing about them. An app that wants different
195
+ // values and not helper options, so the Ruby surface stays unchanged
196
+ // and an island stamps nothing about them. An app that wants different
196
197
  // numbers subclasses and re-registers.
197
198
  //
198
- // busyDelay ms before a round trip is worth mentioning. A localhost
199
- // trip measures 18–25 ms, so 150 suppresses that flicker
200
- // outright while a 150 ms-RTT link crosses it about exactly.
201
- // Borrowed from Turbo's progress bar, which waits ~500 ms.
199
+ // busyDelay ms before a round trip is worth mentioning — long enough
200
+ // to swallow a localhost trip (18–25 ms) without flicker.
202
201
  // busyGrace ms to wait after an ack for a Turbo render still in
203
- // flight. The ack travels the island's own socket; a
204
- // broadcast takes a pubsub hop, and measured against
205
- // generated output the direct frame beats the paint by
206
- // 3.3–3.6 ms. That gap belongs to the server and its
207
- // backend, so it does not shrink on a fast link — and Redis
208
- // widens it. Hence a bound in tens of ms, not single digits.
202
+ // flight. The ack rides the island's own socket while the
203
+ // broadcast takes a pubsub hop, so the ack routinely wins
204
+ // by a few ms — a gap owned by the server's backend, which
205
+ // no fast link shrinks. Hence tens of ms, not single digits.
209
206
  // busyCeiling ms before a trip is declared stalled rather than silently
210
207
  // cleared. On a bad link "we lost it" beats "nothing
211
208
  // happened".
@@ -220,11 +217,10 @@ export class ChannelController extends Controller {
220
217
 
221
218
  // The synchronous half of connect: everything a listener firing in the
222
219
  // next millisecond depends on. HibikiController attaches its delegated
223
- // listeners before openSubscription is even called, and the window
224
- // before the subscription confirms is ~3 serialised round trips on the
225
- // Turbo-broadcast path (Turbo's own subscribe, a SECOND websocket
226
- // handshake, then ours) — tens of ms on localhost, about a second on a
227
- // real remote link. Clicks in it used to vanish without a trace.
220
+ // listeners before openSubscription is even called, and on the
221
+ // Turbo-broadcast path the subscription takes ~3 serialised round
222
+ // trips to confirm — up to a second on a real link. Clicks in that
223
+ // window used to vanish without a trace.
228
224
  prepareTransport() {
229
225
  this.aborted = false
230
226
  this.subscribed = false
@@ -237,15 +233,16 @@ export class ChannelController extends Controller {
237
233
  }
238
234
 
239
235
  async openSubscription() {
240
- consumer ??= createConsumer()
241
236
  this.defineForwarders()
242
237
  // Turbo-broadcast transport: wait for the element's own stream source
243
238
  // to confirm before subscribing, so the graph's dependency-collecting
244
239
  // first run broadcasts into a live stream. No source (transmit
245
- // transport) → this path is fully synchronous, exactly as before.
240
+ // transport) → only the consumer await below, a microtask once it
241
+ // exists (turbo-rails memoizes it).
246
242
  const source = this.streamSource()
247
243
  if (source) await streamConnected(source)
248
- if (this.aborted) return // disconnected during the await
244
+ consumer ??= await cable.getConsumer()
245
+ if (this.aborted) return // disconnected during the awaits
249
246
  this.subscription = consumer.subscriptions.create(this.subscribeParams(), {
250
247
  received: (data) => this.handleMessage(data),
251
248
  connected: () => this.linkOpened(),
@@ -274,24 +271,23 @@ export class ChannelController extends Controller {
274
271
  // can never overwrite it. (`hbk` is the second reserved payload key —
275
272
  // ActionCable's own Subscription#perform already writes `action`.)
276
273
  //
277
- // Returns the seq so a caller that knows which control fired can attach
278
- // it; nobody has to. Returns undefined instead when the payload went
279
- // nowhere: the socket turned out to be closed under a subscription still
280
- // believed live, or the island was already offline. Public API on the
281
- // island controller (the header's "App JS reaching the graph") — truthy
282
- // = accepted, falsy = dropped and the caller owns recovery.
274
+ // Public API on the island controller (the header's "App JS reaching
275
+ // the graph"). Returns the seq when the action was accepted, so a
276
+ // caller that knows which control fired can attach it; returns
277
+ // undefined when the payload went nowhere — the socket turned out to
278
+ // be closed, or the island was already offline — and the caller owns
279
+ // recovery.
283
280
  perform(action, payload = {}) {
284
281
  const seq = ++this.seq
285
282
  payload.hbk = seq
286
283
  if (this.subscribed) {
287
284
  this.beginBusy(seq)
288
- // Action Cable's Subscription#perform returns false when the socket
289
- // is not open — the gap between the socket dying and the connection
290
- // monitor noticing, during which `subscribed` still says live. The
291
- // frame went nowhere: settle rather than letting the trip stall out
292
- // at the ceiling, and stamp `offline` now instead of when the
293
- // monitor catches up. The monitor still owns reconnecting; its
294
- // `connected` callback restores `ready` exactly as after a real gap.
285
+ // Subscription#perform returns false when the socket is not open —
286
+ // the gap between the socket dying and the connection monitor
287
+ // noticing, during which `subscribed` still says live. The frame
288
+ // went nowhere: settle the trip rather than let it stall out at
289
+ // the ceiling, and stamp `offline` now instead of when the monitor
290
+ // catches up. The monitor still owns reconnecting.
295
291
  if (this.subscription.perform(action, payload) === false) {
296
292
  this.settle(seq)
297
293
  this.linkClosed()
@@ -299,19 +295,17 @@ export class ChannelController extends Controller {
299
295
  }
300
296
  return seq
301
297
  }
302
- // Queue rather than drop while the subscription is still coming up.
303
- // ActionCable's Subscription#perform silently returns false on a socket
304
- // that is not open yet, so this was a silent no-op for the whole
305
- // connect window — and the trigger has to be the `connected` callback,
306
- // not "the subscription object exists", because the second websocket's
307
- // handshake sits between the two.
298
+ // Queue rather than drop while the subscription is still coming up:
299
+ // Subscription#perform silently returns false on a socket not open
300
+ // yet, so the whole connect window used to be a silent no-op. The
301
+ // gate must be the `connected` callback, not "the subscription
302
+ // object exists" — the second websocket's handshake sits between
303
+ // the two.
308
304
  //
309
- // Only that first window. Once the link has been up, a gap means the
310
- // socket dropped, and reconnecting builds a FRESH graph server-side
311
- // with default state — so replaying intent formed against the old one
312
- // is worse than dropping it. The island is stamped `offline` for the
313
- // whole gap, which is the signal the connect window cannot give: there
314
- // the page is painted and looks live.
305
+ // Only that first window. Once the link has been up, a gap means
306
+ // the socket dropped, and the island is stamped `offline` for the
307
+ // whole gap — the signal the connect window cannot give, where the
308
+ // page is painted and looks live.
315
309
  if (!this.connectedOnce) {
316
310
  this.beginBusy(seq)
317
311
  this.queued.push([action, payload])
@@ -336,10 +330,10 @@ export class ChannelController extends Controller {
336
330
  linkClosed() {
337
331
  this.subscribed = false
338
332
  this.setState("offline")
339
- // Deliberately NOT queued across the gap. A reconnect builds a fresh
340
- // graph server-side with default state, so replaying intent formed
341
- // against the old one is worse than dropping it. Outstanding records
342
- // settle for the same reason: their acks are never coming.
333
+ // Deliberately NOT queued across the gap: a reconnect builds a fresh
334
+ // graph server-side, so replayed intent would land on state it was
335
+ // not formed against. Outstanding records settle for the same
336
+ // reason — their acks are never coming.
343
337
  this.queued = []
344
338
  this.settleAll()
345
339
  }
@@ -505,10 +499,10 @@ export class ChannelController extends Controller {
505
499
  // Auto-forwarding: every data-action token addressed to this identifier
506
500
  // whose method the subclass did NOT declare gets a generated forwarder
507
501
  // that performs the underscored action with no payload — so plain
508
- // forwards need zero code. Declared methods always win. Methods are
509
- // defined at connect: action NAMES first appearing in later-inserted
510
- // markup aren't discovered (names already seen keep working anywhere,
511
- // Stimulus binds the elements itself).
502
+ // forwards need zero code. Declared methods always win. Forwarders are
503
+ // defined at connect, so an action NAME first appearing in
504
+ // later-inserted markup isn't discovered; a name already seen keeps
505
+ // working anywhere, because Stimulus binds the elements itself.
512
506
  defineForwarders() {
513
507
  for (const control of this.element.querySelectorAll("[data-action]")) {
514
508
  for (const token of control.dataset.action.trim().split(/\s+/)) {
@@ -563,9 +557,9 @@ export default class HibikiController extends ChannelController {
563
557
  islands.set(this.element, this)
564
558
  // Before the listeners, not after: a click delegated in the next
565
559
  // millisecond reaches perform(), which needs the busy map and the
566
- // queue to exist. This is also what stamps data-hibiki-state
567
- // ="connecting" synchronously, so the island can be dimmed for the
568
- // whole window rather than from the middle of it.
560
+ // queue to exist. This also stamps data-hibiki-state="connecting"
561
+ // synchronously, so the island can be dimmed for the whole window
562
+ // rather than from the middle of it.
569
563
  this.prepareTransport()
570
564
 
571
565
  // Root-scoped delegation (bound to the island, not document): controls
@@ -674,13 +668,12 @@ export default class HibikiController extends ChannelController {
674
668
  // A fallback control's native behavior IS the degraded path: unless
675
669
  // the island is `ready`, stand aside — no perform, no queueing — and
676
670
  // the browser follows the href or submits the form to its own
677
- // action=. Deliberately not the connect-window queue: a queued gesture
678
- // renders nothing until the link comes up, while the control's
679
- // destination answers immediately. Two touches before stepping back:
680
- // a confirm: still gates the native behavior (scripts are running, so
681
- // a destructive submit must not slip past the dialog), and a form's
682
- // authenticity_token is freshened — server-rendered repaints carry no
683
- // session, so their forms embed a stale token or none at all.
671
+ // action=. Deliberately not the connect-window queue: a queued
672
+ // gesture renders nothing until the link comes up, while the
673
+ // control's destination answers immediately. Two touches before
674
+ // stepping back: a confirm: still gates the native behavior
675
+ // (scripts are running, so a destructive submit must not slip past
676
+ // the dialog), and the form's CSRF token is freshened.
684
677
  const fallback = "hibikiFallback" in control.dataset
685
678
  if (fallback && this.state !== "ready") {
686
679
  const message = control.dataset.hibikiConfirm
data/config/importmap.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  # Merged into the host app's import map by the Engine's importmap
4
4
  # initializer (importmap-rails reads every path in config.importmap.paths).
5
- # The app supplies the "@hotwired/stimulus" and "@rails/actioncable" pins —
6
- # both are part of the Rails 8 default stack.
5
+ # The app supplies the "@hotwired/stimulus" and "@hotwired/turbo-rails" pins
6
+ # — both are part of the Rails 8 default stack. The client takes Action
7
+ # Cable from turbo-rails' consumer, so no "@rails/actioncable" pin is needed.
7
8
  pin "hibiki-rails", to: "hibiki.js"
@@ -18,7 +18,7 @@ module Hibiki
18
18
 
19
19
  desc "Wires the packaged hibiki client: registers the \"hibiki\" Stimulus " \
20
20
  "controller, includes Hibiki::Rails::Helpers in ApplicationHelper, " \
21
- "creates the ApplicationCable boilerplate, and pins @rails/actioncable."
21
+ "and creates the ApplicationCable boilerplate."
22
22
 
23
23
  # Exactly the lines `stimulus:manifest:update` emits for the shim,
24
24
  # so a later manifest run converges instead of duplicating.
@@ -38,14 +38,6 @@ module Hibiki
38
38
  "connection.rb.tt" => "app/channels/application_cable/connection.rb"
39
39
  }.freeze
40
40
 
41
- # Same event: the stock importmap has no @rails/actioncable pin
42
- # until the first `rails g channel` adds it. The packaged client
43
- # imports it, so pin it here (the asset ships in actioncable).
44
- PIN = <<~RUBY
45
-
46
- pin "@rails/actioncable", to: "actioncable.esm.js"
47
- RUBY
48
-
49
41
  # The registration lives in a file-backed shim so that
50
42
  # `stimulus:manifest:update` (which rewrites index.js wholesale from
51
43
  # the *_controller.js files) re-derives it instead of dropping it.
@@ -81,11 +73,9 @@ module Hibiki
81
73
  end
82
74
  end
83
75
 
84
- def pin_actioncable
85
- return bundler_note unless importmap?
86
- return say_status :identical, IMPORTMAP, :blue if wired?(IMPORTMAP, "@rails/actioncable")
87
-
88
- append_to_file IMPORTMAP, PIN
76
+ # Bundler apps get the client from npm, not from the engine's asset.
77
+ def note_npm_package
78
+ bundler_note unless importmap?
89
79
  end
90
80
 
91
81
  private
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "cgi/escape"
4
4
  require "json"
5
+ require "securerandom"
5
6
 
6
7
  module Hibiki
7
8
  module Rails
@@ -24,7 +25,8 @@ module Hibiki
24
25
  # Both helpers return a `{ data: { ... } }` hash: splat it into Phlex
25
26
  # element methods or Rails tag helpers (`tag.div(**hibiki_island(...))`).
26
27
  # When the element needs other attributes on the same `data:` key, merge
27
- # the hashes yourself (Phlex's `mix` does this).
28
+ # the hashes yourself (Phlex's `mix` does this). In ERB, #island wraps
29
+ # the root, the cid, and the Turbo stream source into one block helper.
28
30
  #
29
31
  # The emitted attribute names are a private contract between these
30
32
  # helpers and the gem's JS — they version together; don't hand-write
@@ -53,7 +55,12 @@ module Hibiki
53
55
  # the emitted markup instead of being an invisible default.
54
56
  DEFAULT_INPUT_DEBOUNCE = 250
55
57
 
56
- private_constant :VALUE_NAME, :VALUE_TAG, :EVENT_NAME
58
+ # How an island's channel sends HTML back: Turbo broadcasts to a named
59
+ # stream (the root needs a stream source inside), or transmit down the
60
+ # subscription itself (it does not).
61
+ TRANSPORTS = %i[broadcast transmit].freeze
62
+
63
+ private_constant :VALUE_NAME, :VALUE_TAG, :EVENT_NAME, :TRANSPORTS
57
64
 
58
65
  # The shared name validator for both halves of a reactive value (the
59
66
  # view-side data-hibiki-value placeholder and the channel's
@@ -67,6 +74,23 @@ module Hibiki
67
74
  name
68
75
  end
69
76
 
77
+ # A tag name lands in raw markup, so it is allowlisted wherever a
78
+ # helper takes one.
79
+ def self.tag_name(name, of)
80
+ name = name.to_s
81
+ raise ArgumentError, "#{of} tag #{name.inspect} must match #{VALUE_TAG.inspect}" unless VALUE_TAG.match?(name)
82
+
83
+ name
84
+ end
85
+
86
+ # #island's transport option, allowlisted so a typo names itself.
87
+ def self.transport(value)
88
+ return value if TRANSPORTS.include?(value)
89
+
90
+ raise ArgumentError,
91
+ "island transport #{value.inspect} must be one of #{TRANSPORTS.map(&:inspect).join(', ')}"
92
+ end
93
+
70
94
  # The shared validator for both halves of an `event->action` token.
71
95
  def self.event_name(name)
72
96
  name = name.to_s
@@ -105,6 +129,33 @@ module Hibiki
105
129
  { data: }
106
130
  end
107
131
 
132
+ # The ERB spelling of the island root: a fresh cid, the #hibiki_island
133
+ # root, and the channel's own `turbo_stream_from channel_name, cid`
134
+ # inside it, in one block. The block receives the cid.
135
+ #
136
+ # <%= island CounterChannel do |cid| %>
137
+ # ...
138
+ # <% end %>
139
+ #
140
+ # `transport: :transmit` leaves the stream source out — for a channel
141
+ # that transmits, or one that overrides #stream_name and writes its own
142
+ # turbo_stream_from inside the block. Other keywords land on the root
143
+ # element; a `data:` hash is merged beneath the island's own keys.
144
+ # Class-only: a dynamic name is `"#{kind.camelize}Channel".constantize`
145
+ # at the call site, never taken from a request param. Phlex components
146
+ # keep `div(**hibiki_island(...))`.
147
+ def island(channel, cid: nil, params: nil, transport: :broadcast, tag_name: :div, **attributes,
148
+ &block)
149
+ island_guards!(channel, block)
150
+ tag_name = Helpers.tag_name(tag_name, "island")
151
+ transport = Helpers.transport(transport)
152
+ cid ||= SecureRandom.uuid
153
+ attributes[:data] = attributes[:data].to_h.merge(hibiki_island(channel, cid:, params:)[:data])
154
+ content = capture(cid, &block)
155
+ content = island_stream(channel, cid, content) if transport == :broadcast
156
+ tag.public_send(tag_name, content, **attributes)
157
+ end
158
+
108
159
  # Forward an event on this element as a channel action.
109
160
  #
110
161
  # `event:` names the event, or a list of them — the left side of the
@@ -169,11 +220,7 @@ module Hibiki
169
220
  # channels. Only the placeholder text is server-rendered: each site
170
221
  # keeps its own tag, classes, and attributes across updates.
171
222
  def reactive(name, placeholder = "", tag_name: :span)
172
- tag_name = tag_name.to_s
173
- unless VALUE_TAG.match?(tag_name)
174
- raise ArgumentError,
175
- "reactive value tag #{tag_name.inspect} must match #{VALUE_TAG.inspect}"
176
- end
223
+ tag_name = Helpers.tag_name(tag_name, "reactive value")
177
224
  html = %(<#{tag_name} data-hibiki-value="#{Helpers.value_name(name)}">) +
178
225
  %(#{CGI.escapeHTML(placeholder.to_s)}</#{tag_name}>)
179
226
  html.respond_to?(:html_safe) ? html.html_safe : html
@@ -185,6 +232,25 @@ module Hibiki
185
232
 
186
233
  private
187
234
 
235
+ # #island's preconditions: a block, an ActionView receiver, a channel
236
+ # class.
237
+ def island_guards!(channel, block)
238
+ raise ArgumentError, "island needs a block" unless block
239
+ unless respond_to?(:output_buffer) && respond_to?(:tag)
240
+ raise ArgumentError, "island is ERB-only; use div(**hibiki_island(...)) in a Phlex component"
241
+ end
242
+ return if channel.is_a?(Class) && channel.respond_to?(:channel_name)
243
+
244
+ raise ArgumentError,
245
+ "island takes a channel class, got #{channel.inspect}; " \
246
+ "constantize a dynamic name at the call site"
247
+ end
248
+
249
+ # The broadcast transport's stream source, ahead of the block's content.
250
+ def island_stream(channel, cid, content)
251
+ safe_join([turbo_stream_from(channel.channel_name, cid), content])
252
+ end
253
+
188
254
  # #on's per-control modifiers, kept out of the token grammar. Each is
189
255
  # omitted when it matches the client's own default, so the common call
190
256
  # still stamps exactly one attribute.
@@ -257,7 +257,7 @@ module Hibiki
257
257
  # #errors when validation fails (the Rails #save convention). On
258
258
  # success the form re-hydrates: callbacks and database defaults may
259
259
  # have moved values, and #persisted? flips after an INSERT.
260
- # rubocop:disable Naming/PredicateMethod -- boolean without a ?, exactly like AR's #save
260
+ # rubocop:disable-next Naming/PredicateMethod -- boolean without a ?, exactly like AR's #save
261
261
  def commit
262
262
  record = __hibiki_record!
263
263
  if record.update(**to_h)
@@ -276,7 +276,6 @@ module Hibiki
276
276
  false
277
277
  end
278
278
  end
279
- # rubocop:enable Naming/PredicateMethod
280
279
 
281
280
  # The raising half. Re-assigns and raises ActiveRecord::RecordInvalid
282
281
  # (no rescue, and no AR constant named here); the nested associations
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hibiki
4
4
  module Rails
5
- VERSION = "0.10.0"
5
+ VERSION = "0.12.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hibiki_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - planetaska