hibiki_rails 0.17.0 → 0.17.2
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 +64 -0
- data/app/assets/javascripts/hibiki.js +122 -10
- data/lib/hibiki/rails/broadcasts.rb +17 -6
- data/lib/hibiki/rails/reactive_form.rb +37 -4
- data/lib/hibiki/rails/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 83e9656d09f3910405a4db7167b526c173158eb1035d5cb5129e4f916c88c278
|
|
4
|
+
data.tar.gz: e22b25605c6522e98cfdc86dcd685e62b3c60ed31ca865eb2daa53f74aaaf946
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8c459eb9453de6af88c6e7859849a41dcbf7c0daa5d2e79da12e5ac8f729f4b85093e9d10c176d97cb8cd5141ef47ffeb0bcb2482aa03b223e42b2af4b13d9a6
|
|
7
|
+
data.tar.gz: 7ccd090ef5fdd72735d7bb45d356a1f7085fb6c323d42692c91b7226a5fd6ac058f0ea477efaa6d7693dfbfe66254d85c358e8ea5ca7d3fcd49f6fde56366040
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,70 @@ 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.17.2 — 2026-10-11
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
**A save the model refused could leave a `reactive_association` form unable
|
|
12
|
+
to save.** The form keeps one record between attempts. When a `commit` was
|
|
13
|
+
refused, the join rows were rolled back, but the record still remembered the
|
|
14
|
+
selection that attempt had written, and the next `commit` was compared with
|
|
15
|
+
that memory instead of the table. If the refused attempt had removed an id
|
|
16
|
+
that the next one still held, the retry inserted a join row that was already
|
|
17
|
+
there. With a unique index on the pair, which the `multiselect` generator
|
|
18
|
+
writes, that raised `ActiveRecord::RecordNotUnique` and the visitor saw a
|
|
19
|
+
Save that did nothing, every time, until the form was opened again. Without
|
|
20
|
+
the index it wrote a duplicate row.
|
|
21
|
+
|
|
22
|
+
- Any refused save could set it up, not only one about the association: a
|
|
23
|
+
blank name saved together with a removed tag was enough.
|
|
24
|
+
- A refused `commit` or `commit!` now unloads the associations named in
|
|
25
|
+
`reactive_association`, and the join association behind a
|
|
26
|
+
`has_many :through` or a `has_and_belongs_to_many`. The form keeps the
|
|
27
|
+
visitor's selection; nothing changes in the channel or the view.
|
|
28
|
+
- An app that calls `record.reload` before `commit` to get round this can
|
|
29
|
+
remove the call.
|
|
30
|
+
- `hibiki_associations` on a form class lists the names it declared.
|
|
31
|
+
|
|
32
|
+
**A form that had saved once could save its `reactive_association` wrongly
|
|
33
|
+
after another session changed the same record.** The cause is the same
|
|
34
|
+
memory. If the other session had added an id this form then added too, the
|
|
35
|
+
commit raised `ActiveRecord::RecordNotUnique`. If it had removed an id this
|
|
36
|
+
form still showed, the commit returned `true` and did not write it.
|
|
37
|
+
|
|
38
|
+
- `commit` now reads the association from the table each time, at the cost
|
|
39
|
+
of one query per association when it was already loaded.
|
|
40
|
+
- The later save still wins: the form's selection replaces the other
|
|
41
|
+
session's, as its other fields already did.
|
|
42
|
+
- Nested forms (`reactive_nested`) are not changed.
|
|
43
|
+
- The npm package is unchanged apart from its version.
|
|
44
|
+
|
|
45
|
+
## 0.17.1 — 2026-10-09
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
**Renders sent over the broadcast transport could reach the page out of
|
|
50
|
+
order.** Action Cable hands each broadcast to a pool of threads, so two
|
|
51
|
+
renders a channel sent a millisecond apart could be delivered in either
|
|
52
|
+
order. When both drew the same target, the page ended on the older one and
|
|
53
|
+
stayed wrong until something rendered that target again: a Save button left
|
|
54
|
+
enabled, a note that contradicted the server's state. It was always
|
|
55
|
+
possible; since 0.17.0 sends a debounced field's text just ahead of a click
|
|
56
|
+
or a submit, any form with one did it routinely.
|
|
57
|
+
|
|
58
|
+
- `broadcast_replace` and `broadcast_morph` now number their renders, and
|
|
59
|
+
the client applies them in the order the channel sent them. Nothing changes
|
|
60
|
+
in the channel or the view.
|
|
61
|
+
- A render that arrives ahead of an earlier one waits for it, for at most
|
|
62
|
+
100 ms. One that arrives later than that is still drawn, unless a newer
|
|
63
|
+
render has drawn the same target since.
|
|
64
|
+
- `broadcast_refresh`, the transmit transport, and streams an app sends with
|
|
65
|
+
`Turbo::StreamsChannel` itself are not affected and not changed.
|
|
66
|
+
- Both halves are needed, so update the gem and the npm package together. A
|
|
67
|
+
page on one side only behaves as 0.17.0 did.
|
|
68
|
+
- `broadcast_replace` and `broadcast_morph` also accept `attributes:`, which
|
|
69
|
+
are kept on the stream beside the gem's own.
|
|
70
|
+
|
|
7
71
|
## 0.17.0 — 2026-10-08
|
|
8
72
|
|
|
9
73
|
### Added
|
|
@@ -66,6 +66,9 @@
|
|
|
66
66
|
// beside them existing to change the text
|
|
67
67
|
// focus sites data-hibiki-focus="true|select" takes focus, and has its
|
|
68
68
|
// text selected, when a render this tab caused inserts it
|
|
69
|
+
// turbo streams data-hibiki-seq="3" the render's number, and
|
|
70
|
+
// data-hibiki-from="CounterChannel/<cid>" the island it is for;
|
|
71
|
+
// see "Render order" below
|
|
69
72
|
//
|
|
70
73
|
// The client writes the protocol's other half at runtime — read-only to
|
|
71
74
|
// app code, addressed to app CSS:
|
|
@@ -118,6 +121,16 @@
|
|
|
118
121
|
// nearest id-bearing ancestor. Only controls: when none of these is there,
|
|
119
122
|
// focus is left where the browser put it. Any render, whoever caused it.
|
|
120
123
|
//
|
|
124
|
+
// Render order. Action Cable hands each broadcast to a worker pool, so two
|
|
125
|
+
// renders the channel sent a millisecond apart can arrive in either order,
|
|
126
|
+
// and the page would end on the older one. The channel numbers its Turbo
|
|
127
|
+
// streams and the island applies them in that order: one that arrives ahead
|
|
128
|
+
// of its turn is held until the missing ones come, or until `orderGrace`
|
|
129
|
+
// runs out on one that never will. A render that turns up after it was
|
|
130
|
+
// given up on is still drawn, unless a later one has drawn the same target
|
|
131
|
+
// since. A stream with no number, or another island's, is left alone. The
|
|
132
|
+
// transmit transport needs none of this: it writes to the socket in order.
|
|
133
|
+
//
|
|
121
134
|
// App JS reaching the graph — the ONE public seam. A gesture that needs
|
|
122
135
|
// script (drag-and-drop, a third-party widget) fires its action through
|
|
123
136
|
// the island's OWN subscription: `perform(action, payload)` on the island
|
|
@@ -753,6 +766,11 @@ export default class HibikiController extends ChannelController {
|
|
|
753
766
|
// stamps no data-hibiki-params-value.
|
|
754
767
|
static values = { channel: String, params: Object }
|
|
755
768
|
|
|
769
|
+
// ms a Turbo render that arrived ahead of its turn waits for the ones
|
|
770
|
+
// before it (see inOrder). Only a lost broadcast waits it out; a late one
|
|
771
|
+
// is a pool hop behind, a millisecond or two.
|
|
772
|
+
static orderGrace = 100
|
|
773
|
+
|
|
756
774
|
// The island stamps its channel; no inference.
|
|
757
775
|
channelName() {
|
|
758
776
|
return this.channelValue
|
|
@@ -833,19 +851,23 @@ export default class HibikiController extends ChannelController {
|
|
|
833
851
|
// Re-scan at the points a fragment can be swapped under us, rather
|
|
834
852
|
// than blanket-observing the document: a MutationObserver over the page
|
|
835
853
|
// is a real per-mutation cost paid by every app on it.
|
|
854
|
+
this.resetOrder()
|
|
836
855
|
this.streamRender = (event) => {
|
|
837
856
|
const render = event.detail.render
|
|
857
|
+
const stream = event.detail.newStream
|
|
858
|
+
// Taken now, not when the render's turn comes: see arrival().
|
|
838
859
|
const arrival = this.arrival()
|
|
839
|
-
event.detail.render =
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
860
|
+
event.detail.render = (streamElement) =>
|
|
861
|
+
this.inOrder(stream, async () => {
|
|
862
|
+
await render(streamElement)
|
|
863
|
+
// The Turbo transport's paint. Counting it here is what lets an ack
|
|
864
|
+
// settle immediately instead of waiting out its grace window. The
|
|
865
|
+
// listener is on the document, so an unrelated broadcast counts too
|
|
866
|
+
// — which at worst settles an already-acked trip a few ms early,
|
|
867
|
+
// never one that has not been acked at all.
|
|
868
|
+
this.renders++
|
|
869
|
+
this.scan(arrival)
|
|
870
|
+
})
|
|
849
871
|
}
|
|
850
872
|
document.addEventListener("turbo:before-stream-render", this.streamRender)
|
|
851
873
|
// A refresh stream morphs the page after its own render has returned,
|
|
@@ -882,9 +904,99 @@ export default class HibikiController extends ChannelController {
|
|
|
882
904
|
this.unsent.clear()
|
|
883
905
|
this.marks.clear()
|
|
884
906
|
this.observer.disconnect()
|
|
907
|
+
this.resetOrder()
|
|
885
908
|
super.disconnect()
|
|
886
909
|
}
|
|
887
910
|
|
|
911
|
+
// A reconnect builds a fresh graph, which numbers its renders from 1.
|
|
912
|
+
linkClosed() {
|
|
913
|
+
this.resetOrder()
|
|
914
|
+
super.linkClosed()
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ── Render order ──────────────────────────────────────────────────────
|
|
918
|
+
//
|
|
919
|
+
// `due` is the number of the next render to apply and `held` the renders
|
|
920
|
+
// waiting on it, by number. `missed` and `drawn` exist only after a gap
|
|
921
|
+
// was given up on: the numbers skipped, and the last number drawn into
|
|
922
|
+
// each target since, which is what tells a late render it is stale.
|
|
923
|
+
|
|
924
|
+
// Run `job` (the render and what follows it) when the stream's turn
|
|
925
|
+
// comes, or never when it is stale. Jobs are CALLED in order and not
|
|
926
|
+
// awaited: a call reaches the swap, or the motion module's queue, before
|
|
927
|
+
// it returns, and that queue keeps the order from there.
|
|
928
|
+
inOrder(stream, job) {
|
|
929
|
+
const seq = Number(stream?.getAttribute?.("data-hibiki-seq"))
|
|
930
|
+
if (!seq || stream.getAttribute("data-hibiki-from") !== `${this.channelValue}/${this.cidValue}`) {
|
|
931
|
+
return job()
|
|
932
|
+
}
|
|
933
|
+
const target = stream.getAttribute("target")
|
|
934
|
+
const run = () => {
|
|
935
|
+
this.drew(target, seq)
|
|
936
|
+
return job()
|
|
937
|
+
}
|
|
938
|
+
if (seq < this.due) {
|
|
939
|
+
// Late. A number never given up on is a repeat, or older than
|
|
940
|
+
// anything remembered.
|
|
941
|
+
const stale = !this.missed.delete(seq) || this.drawn.get(target) > seq
|
|
942
|
+
return stale ? Promise.resolve() : run()
|
|
943
|
+
}
|
|
944
|
+
if (seq > this.due) {
|
|
945
|
+
return new Promise((resolve) => {
|
|
946
|
+
this.held.set(seq, () => resolve(run()))
|
|
947
|
+
this.orderTimer ??= setTimeout(() => this.giveUp(), this.constructor.orderGrace)
|
|
948
|
+
})
|
|
949
|
+
}
|
|
950
|
+
const done = run()
|
|
951
|
+
this.due++
|
|
952
|
+
this.release()
|
|
953
|
+
return done
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Apply the held renders that now follow on. Any still held wait on a
|
|
957
|
+
// new gap, with a new grace.
|
|
958
|
+
release() {
|
|
959
|
+
for (let run; (run = this.held.get(this.due)); this.due++) {
|
|
960
|
+
this.held.delete(this.due)
|
|
961
|
+
run()
|
|
962
|
+
}
|
|
963
|
+
clearTimeout(this.orderTimer)
|
|
964
|
+
this.orderTimer = this.held.size
|
|
965
|
+
? setTimeout(() => this.giveUp(), this.constructor.orderGrace)
|
|
966
|
+
: undefined
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// The grace ran out: move past the missing numbers to the first held.
|
|
970
|
+
giveUp() {
|
|
971
|
+
const first = Math.min(...this.held.keys())
|
|
972
|
+
for (let seq = this.due; seq < first; seq++) this.missed.add(seq)
|
|
973
|
+
this.missedAt = performance.now()
|
|
974
|
+
this.due = first
|
|
975
|
+
this.release()
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// Remember what was drawn while a missed render may still turn up, and
|
|
979
|
+
// no longer: one later than the ceiling is lost by the same measure a
|
|
980
|
+
// trip is.
|
|
981
|
+
drew(target, seq) {
|
|
982
|
+
if (performance.now() - this.missedAt > this.constructor.busyCeiling) this.missed.clear()
|
|
983
|
+
if (!this.missed.size) return this.drawn.clear()
|
|
984
|
+
if (!(this.drawn.get(target) > seq)) this.drawn.set(target, seq)
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Start counting again from 1. Renders still held are the old graph's
|
|
988
|
+
// last word, so they are applied first, in order.
|
|
989
|
+
resetOrder() {
|
|
990
|
+
clearTimeout(this.orderTimer)
|
|
991
|
+
this.orderTimer = undefined
|
|
992
|
+
const held = [...(this.held ?? [])].sort(([a], [b]) => a - b)
|
|
993
|
+
this.due = 1
|
|
994
|
+
this.held = new Map()
|
|
995
|
+
this.missed = new Set()
|
|
996
|
+
this.drawn = new Map()
|
|
997
|
+
for (const [, run] of held) run()
|
|
998
|
+
}
|
|
999
|
+
|
|
888
1000
|
// The other swap point: hibiki's own transmit transport. A held swap
|
|
889
1001
|
// hands back a promise; the replacement sentinel exists only after it.
|
|
890
1002
|
received(data) {
|
|
@@ -17,16 +17,16 @@ module Hibiki
|
|
|
17
17
|
|
|
18
18
|
# Replace the element with DOM id `target` (accepts partial:/locals:,
|
|
19
19
|
# html:, or anything Turbo's renderer takes).
|
|
20
|
-
def broadcast_replace(target:, **rendering)
|
|
21
|
-
Turbo::StreamsChannel.broadcast_replace_to(
|
|
20
|
+
def broadcast_replace(target:, attributes: {}, **rendering)
|
|
21
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
22
|
+
*stream_name, target:, attributes: attributes.merge(render_stamp), **rendering
|
|
23
|
+
)
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
# Replace via Turbo 8 morphing (<turbo-stream action="replace"
|
|
25
27
|
# method="morph">): keeps focus/scroll where a plain replace resets.
|
|
26
|
-
def broadcast_morph(target:, **rendering)
|
|
27
|
-
|
|
28
|
-
*stream_name, target:, attributes: { method: :morph }, **rendering
|
|
29
|
-
)
|
|
28
|
+
def broadcast_morph(target:, attributes: {}, **rendering)
|
|
29
|
+
broadcast_replace(target:, attributes: { method: :morph }.merge(attributes), **rendering)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
# Tell the page to refresh itself (Turbo 8 morph-everything style).
|
|
@@ -47,6 +47,17 @@ module Hibiki
|
|
|
47
47
|
broadcast_refresh
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
|
+
|
|
51
|
+
# The number each render carries, and the island it is for. Action
|
|
52
|
+
# Cable hands every broadcast to a worker pool, so two sent a
|
|
53
|
+
# millisecond apart can reach the page in either order; the client
|
|
54
|
+
# applies them by number. Counted per subscription from 1, on the
|
|
55
|
+
# graph thread alone. A refresh carries none: it fetches the page, so
|
|
56
|
+
# its order does not matter. Part of the private data-hibiki contract.
|
|
57
|
+
def render_stamp
|
|
58
|
+
@__hibiki_renders = @__hibiki_renders.to_i + 1
|
|
59
|
+
{ "data-hibiki-seq": @__hibiki_renders, "data-hibiki-from": "#{self.class.name}/#{params[:cid]}" }
|
|
60
|
+
end
|
|
50
61
|
end
|
|
51
62
|
end
|
|
52
63
|
end
|
|
@@ -77,6 +77,7 @@ module Hibiki
|
|
|
77
77
|
def reactive_association(*names)
|
|
78
78
|
raise "#{self}: declare reactive_attributes before reactive_association" unless __hibiki_model_declared?
|
|
79
79
|
|
|
80
|
+
@hibiki_associations = [*hibiki_associations, *names.map(&:to_sym)]
|
|
80
81
|
names.each { |name| __hibiki_ids_signal(name) }
|
|
81
82
|
end
|
|
82
83
|
|
|
@@ -108,6 +109,10 @@ module Hibiki
|
|
|
108
109
|
@hibiki_nested || __hibiki_inherited(:hibiki_nested) || {}
|
|
109
110
|
end
|
|
110
111
|
|
|
112
|
+
def hibiki_associations
|
|
113
|
+
@hibiki_associations || __hibiki_inherited(:hibiki_associations) || []
|
|
114
|
+
end
|
|
115
|
+
|
|
111
116
|
# The child form class, resolved on every use like hibiki_model so
|
|
112
117
|
# a String declaration never pins a constant across a reload.
|
|
113
118
|
def hibiki_nested_form(name)
|
|
@@ -259,7 +264,7 @@ module Hibiki
|
|
|
259
264
|
# have moved values, and #persisted? flips after an INSERT.
|
|
260
265
|
# rubocop:disable-next Naming/PredicateMethod -- boolean without a ?, exactly like AR's #save
|
|
261
266
|
def commit
|
|
262
|
-
record =
|
|
267
|
+
record = __hibiki_unloaded_record
|
|
263
268
|
if record.update(**to_h)
|
|
264
269
|
# Unload before re-hydrating, so the reload honors the
|
|
265
270
|
# association's own scope and drops destroyed rows.
|
|
@@ -272,13 +277,13 @@ module Hibiki
|
|
|
272
277
|
# update APPENDS its built new children to the in-memory
|
|
273
278
|
# association — a later success would insert them all again.
|
|
274
279
|
# Unloading makes the next attempt start clean.
|
|
275
|
-
|
|
280
|
+
__hibiki_unload(record)
|
|
276
281
|
false
|
|
277
282
|
end
|
|
278
283
|
end
|
|
279
284
|
|
|
280
285
|
# The raising half. Re-assigns and raises ActiveRecord::RecordInvalid
|
|
281
|
-
# (no rescue, and no AR constant named here); the
|
|
286
|
+
# (no rescue, and no AR constant named here); the record's associations
|
|
282
287
|
# are unloaded even on the raise, so a rescued retry starts clean.
|
|
283
288
|
def commit!
|
|
284
289
|
return true if commit
|
|
@@ -287,7 +292,7 @@ module Hibiki
|
|
|
287
292
|
begin
|
|
288
293
|
record.update!(**to_h)
|
|
289
294
|
ensure
|
|
290
|
-
|
|
295
|
+
__hibiki_unload(record)
|
|
291
296
|
end
|
|
292
297
|
end
|
|
293
298
|
|
|
@@ -359,6 +364,34 @@ module Hibiki
|
|
|
359
364
|
self.class.hibiki_nested.each_key { |name| record.public_send(name).reset }
|
|
360
365
|
end
|
|
361
366
|
|
|
367
|
+
# What a refused attempt leaves behind on the record.
|
|
368
|
+
def __hibiki_unload(record)
|
|
369
|
+
__hibiki_reset_nested(record)
|
|
370
|
+
__hibiki_reset_associations(record)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# A refused update rolls the join rows back, but the ids writer has
|
|
374
|
+
# already left the association holding the refused set, and the join
|
|
375
|
+
# association behind a :through (or a has_and_belongs_to_many) holding
|
|
376
|
+
# the join records it built. The next ids write would be diffed
|
|
377
|
+
# against that memory instead of the table, so both are unloaded.
|
|
378
|
+
def __hibiki_reset_associations(record)
|
|
379
|
+
self.class.hibiki_associations.each do |name|
|
|
380
|
+
association = record.association(name)
|
|
381
|
+
association.reset
|
|
382
|
+
reflection = association.reflection
|
|
383
|
+
record.association(reflection.through_reflection.name).reset if reflection.through_reflection?
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# The record to write to. Its ids associations are unloaded first, so
|
|
388
|
+
# the ids writer reads the table and not what this record remembers
|
|
389
|
+
# from an earlier commit or a preload: rows another session wrote
|
|
390
|
+
# since would otherwise be inserted twice, or silently left out.
|
|
391
|
+
def __hibiki_unloaded_record
|
|
392
|
+
__hibiki_record!.tap { __hibiki_reset_associations(it) }
|
|
393
|
+
end
|
|
394
|
+
|
|
362
395
|
def __hibiki_record!
|
|
363
396
|
record || raise("#{self.class} has no record — build it with .from(record)")
|
|
364
397
|
end
|
data/lib/hibiki/rails/version.rb
CHANGED
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.17.
|
|
4
|
+
version: 0.17.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- planetaska
|
|
@@ -216,7 +216,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
216
216
|
- !ruby/object:Gem::Version
|
|
217
217
|
version: '0'
|
|
218
218
|
requirements: []
|
|
219
|
-
rubygems_version: 4.0.
|
|
219
|
+
rubygems_version: 4.0.20
|
|
220
220
|
specification_version: 4
|
|
221
221
|
summary: 'Rails glue for hibiki: connection-scoped signal graphs over ActionCable
|
|
222
222
|
+ Turbo Streams.'
|