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 +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +1 -1
- data/app/assets/javascripts/hibiki.js +110 -117
- data/config/importmap.rb +3 -2
- data/lib/generators/hibiki/rails/install/install_generator.rb +4 -14
- data/lib/hibiki/rails/helpers.rb +73 -7
- data/lib/hibiki/rails/reactive_form.rb +1 -2
- data/lib/hibiki/rails/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 514349fcd04fc813cc4833144b4355c56958e9055dda33aefbdc547517d73bfd
|
|
4
|
+
data.tar.gz: 1a9729ad926d72f9bdfb1b4411544893f59c3ebb40d2dd552165bed1299218e5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/
|
|
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,
|
|
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
|
|
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
|
|
9
|
-
//
|
|
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
|
|
14
|
-
// auto-forwarded to the channel
|
|
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
|
|
31
|
-
// time
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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
|
|
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
|
|
64
|
-
//
|
|
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
|
-
//
|
|
73
|
-
// `[data-hibiki-busy] .spinner { display: inline-block }` — so per-row
|
|
74
|
-
// per-button feedback needs no server state
|
|
75
|
-
//
|
|
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,
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
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
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
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 {
|
|
120
|
-
|
|
121
|
-
// One consumer shared by every controller —
|
|
122
|
-
//
|
|
123
|
-
//
|
|
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
|
|
169
|
-
// Server-side repaints render without a session
|
|
170
|
-
// .render has none), so a repainted form embeds a
|
|
171
|
-
// all
|
|
172
|
-
// the DOM may have been repainted
|
|
173
|
-
// its first-paint token
|
|
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
|
|
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
|
|
199
|
-
// trip
|
|
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
|
|
204
|
-
// broadcast takes a pubsub hop,
|
|
205
|
-
//
|
|
206
|
-
//
|
|
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
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
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) →
|
|
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
|
-
|
|
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
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
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
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
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
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
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
|
|
310
|
-
// socket dropped, and
|
|
311
|
-
//
|
|
312
|
-
// is
|
|
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
|
|
340
|
-
// graph server-side
|
|
341
|
-
//
|
|
342
|
-
//
|
|
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.
|
|
509
|
-
// defined at connect
|
|
510
|
-
// markup
|
|
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
|
|
567
|
-
//
|
|
568
|
-
//
|
|
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
|
|
678
|
-
// renders nothing until the link comes up, while the
|
|
679
|
-
// destination answers immediately. Two touches before
|
|
680
|
-
// a confirm: still gates the native behavior
|
|
681
|
-
// a destructive submit must not slip past
|
|
682
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
data/lib/hibiki/rails/helpers.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
data/lib/hibiki/rails/version.rb
CHANGED