hibiki_rails 0.17.1 → 0.18.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: 4919aaf1d8c96e1436138dfc5c0050602e0590447ccf9daebb5598072bf01e2d
4
- data.tar.gz: 214048d87eec87fa482744750f736eb3d3004381864e69c12b1664a10ffaf9a5
3
+ metadata.gz: 915a98a11ca62685f60697d27383e8592978e0c2173b0e984c24eca7736cef55
4
+ data.tar.gz: 070cd14175c3027b164ec0b52378b59178499f3f3474fe3296722f6432c580ab
5
5
  SHA512:
6
- metadata.gz: f1da64047eb007f11f45f55007519808ad717f9b53968273455586c3977486f1e03e2478f9041c05a7f9d889191d6591a7e116e21d918dfb7719b337e0eeed3b
7
- data.tar.gz: 1ed922620989a1997ef4231a7c2e737cbca070ff3c3917966c74b1ed48c28e087e08fd7763152eeb96b713e8675b5126d831b07e5e4dc10e22c31d20cb908fdb
6
+ metadata.gz: 5d07fa8e0a897a21cce85e559e42a02dc78090f89986bc37c24202a36fb81322287a1ae86fb4c5166aa615a4ee8924732bb63020643a2e4db8e8ec72a5d870eb
7
+ data.tar.gz: 993b20b7af2d105e5c067823621eb8050ff426a57a9ac0b0aacccbd2a9fd9136a9a2a6f0f635d7932952f7c4c58bef2645492c67fe1b79acbb28d1c2e86f5a37
data/CHANGELOG.md CHANGED
@@ -4,6 +4,87 @@ 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.18.0 — 2026-10-11
8
+
9
+ ### Added
10
+
11
+ **Instrumentation events.** A channel now publishes
12
+ `ActiveSupport::Notifications` events, so an app can time what a graph does.
13
+ Action Cable's own `perform_action.action_cable` covers only the hand-off to
14
+ the graph's thread; the action, its effects and its renders run after it.
15
+
16
+ | Event | Duration covers | Payload |
17
+ |---|---|---|
18
+ | `subscribe.hibiki_rails` | building the graph and its first renders | `channel` |
19
+ | `unsubscribe.hibiki_rails` | disposing the graph | `channel` |
20
+ | `action.hibiki_rails` | the action and every effect it flushed | `channel`, `action`, `wait`, `renders` |
21
+ | `render.hibiki_rails` | one `broadcast_replace`, `broadcast_morph` or `broadcast_refresh` | `channel`, `target`, `method` |
22
+
23
+ - The events are always on and need no configuration. The gem subscribes to
24
+ none of them. With no subscriber an action costs one to two microseconds
25
+ more than in 0.17.2.
26
+ - A subscriber runs on the graph's thread, so keep it to cheap work in
27
+ memory.
28
+ - An action the channel does not have publishes nothing. A raising action
29
+ publishes, with the exception in the payload.
30
+ - `transmit_value` and `transmit_url` have no event of their own; Action
31
+ Cable's `transmit.action_cable` covers them.
32
+
33
+ **`Hibiki::Rails.registry.tally`** returns the live subscriptions by channel
34
+ class name.
35
+
36
+ **Trips: how long a visitor waited.** With `HibikiController.reportTrips =
37
+ true`, each island times its round trips and sends them with its next action.
38
+ The channel publishes one `trip.hibiki_rails` event for each, with `channel`,
39
+ `action`, `ack` (milliseconds until the server answered) and `paint`
40
+ (milliseconds until the first render arrived, or `nil`).
41
+
42
+ - Off by default. Set it once, on the class, for every island.
43
+ - A dropped or stalled trip is not reported, and neither is the last trip on
44
+ a page, since no action follows it.
45
+ - The figures come from the browser. The channel takes at most five per
46
+ action, only for its own actions, and clamps each to 0 to 60 000 ms.
47
+ - `hbt` joins `hbk` as a reserved payload key. The channel removes it before
48
+ the action method sees its data.
49
+
50
+ ## 0.17.2 — 2026-10-11
51
+
52
+ ### Fixed
53
+
54
+ **A save the model refused could leave a `reactive_association` form unable
55
+ to save.** The form keeps one record between attempts. When a `commit` was
56
+ refused, the join rows were rolled back, but the record still remembered the
57
+ selection that attempt had written, and the next `commit` was compared with
58
+ that memory instead of the table. If the refused attempt had removed an id
59
+ that the next one still held, the retry inserted a join row that was already
60
+ there. With a unique index on the pair, which the `multiselect` generator
61
+ writes, that raised `ActiveRecord::RecordNotUnique` and the visitor saw a
62
+ Save that did nothing, every time, until the form was opened again. Without
63
+ the index it wrote a duplicate row.
64
+
65
+ - Any refused save could set it up, not only one about the association: a
66
+ blank name saved together with a removed tag was enough.
67
+ - A refused `commit` or `commit!` now unloads the associations named in
68
+ `reactive_association`, and the join association behind a
69
+ `has_many :through` or a `has_and_belongs_to_many`. The form keeps the
70
+ visitor's selection; nothing changes in the channel or the view.
71
+ - An app that calls `record.reload` before `commit` to get round this can
72
+ remove the call.
73
+ - `hibiki_associations` on a form class lists the names it declared.
74
+
75
+ **A form that had saved once could save its `reactive_association` wrongly
76
+ after another session changed the same record.** The cause is the same
77
+ memory. If the other session had added an id this form then added too, the
78
+ commit raised `ActiveRecord::RecordNotUnique`. If it had removed an id this
79
+ form still showed, the commit returned `true` and did not write it.
80
+
81
+ - `commit` now reads the association from the table each time, at the cost
82
+ of one query per association when it was already loaded.
83
+ - The later save still wins: the form's selection replaces the other
84
+ session's, as its other fields already did.
85
+ - Nested forms (`reactive_nested`) are not changed.
86
+ - The npm package is unchanged apart from its version.
87
+
7
88
  ## 0.17.1 — 2026-10-09
8
89
 
9
90
  ### Fixed
data/README.md CHANGED
@@ -125,6 +125,52 @@ The generated components are just Rails partials (or Phlex components, if you us
125
125
 
126
126
  Congratulations! Now you have your first reactive component!
127
127
 
128
+ ## Instrumentation
129
+
130
+ A channel publishes `ActiveSupport::Notifications` events. They are always
131
+ on, and the gem subscribes to none of them.
132
+
133
+ | Event | Duration covers | Payload |
134
+ |---|---|---|
135
+ | `subscribe.hibiki_rails` | building the graph and its first renders | `channel` |
136
+ | `unsubscribe.hibiki_rails` | disposing the graph | `channel` |
137
+ | `action.hibiki_rails` | the action and every effect it flushed | `channel`, `action`, `wait`, `renders` |
138
+ | `render.hibiki_rails` | rendering and handing the stream to Action Cable | `channel`, `target`, `method` |
139
+ | `trip.hibiki_rails` | nothing | `channel`, `action`, `ack`, `paint` |
140
+
141
+ ```ruby
142
+ ActiveSupport::Notifications.subscribe("action.hibiki_rails") do |event|
143
+ Rails.logger.info "#{event.payload[:channel]}##{event.payload[:action]} #{event.duration.round(1)}ms"
144
+ end
145
+ ```
146
+
147
+ - `channel` is the class name. `wait` is the milliseconds the action queued
148
+ for the graph's thread. `renders` is how many renders it caused.
149
+ - `method` is `:replace`, `:morph` or `:refresh`. A refresh has no `target`.
150
+ - An action the channel does not have publishes nothing. A raising action
151
+ publishes, with the exception in the payload.
152
+ - Action Cable's `broadcast.action_cable` fires inside the render event, on
153
+ the same thread, and carries the message if you want its size.
154
+ - `Hibiki::Rails.registry.tally` returns the live subscriptions by channel
155
+ class, and `registry.size` the total.
156
+
157
+ **A subscriber runs on the graph's thread.** Keep it to cheap work in
158
+ memory; anything slow holds up that visitor's actions.
159
+
160
+ A `trip` is the browser's own timing of one action: `ack` is the
161
+ milliseconds until the server answered and `paint` the milliseconds until
162
+ its first render arrived, or `nil` when nothing rendered. Reporting is off
163
+ until the app turns it on:
164
+
165
+ ```js
166
+ import HibikiController from "hibiki-rails"
167
+ HibikiController.reportTrips = true
168
+ ```
169
+
170
+ Each island then sends its last trips with its next action. The figures come
171
+ from visitors' browsers, so treat them as reported: the gem bounds them (five
172
+ per action, known action names, 0 to 60 000 ms) and cannot verify them.
173
+
128
174
  ## Documentation
129
175
 
130
176
  Documentation site: <https://planetaska.github.io/hibiki/rails-introduction/>
@@ -284,6 +284,10 @@ export class ChannelController extends Controller {
284
284
  static busyCeiling = 10000
285
285
  static focusGrace = 500
286
286
 
287
+ // Whether to tell the server how long each trip took (see "Trips"
288
+ // below). Off unless an app sets it, once, for every island.
289
+ static reportTrips = false
290
+
287
291
  async connect() {
288
292
  this.prepareTransport()
289
293
  await this.openSubscription()
@@ -302,6 +306,7 @@ export class ChannelController extends Controller {
302
306
  this.seq = 0
303
307
  this.renders = 0
304
308
  this.busy = new Map()
309
+ this.trips = []
305
310
  this.settledAt = -Infinity
306
311
  this.lastControl = null
307
312
  this.queued = []
@@ -345,7 +350,8 @@ export class ChannelController extends Controller {
345
350
  // a sequence number under the reserved `hbk` key and opens a busy record
346
351
  // that the matching ack closes; the seq is stamped LAST so a form field
347
352
  // can never overwrite it. (`hbk` is the second reserved payload key —
348
- // ActionCable's own Subscription#perform already writes `action`.)
353
+ // ActionCable's own Subscription#perform already writes `action`. `hbt`
354
+ // is the third: the trips timed since the last perform.)
349
355
  //
350
356
  // Public API on the island controller (the header's "App JS reaching
351
357
  // the graph"). Returns the seq when the action was accepted, so a
@@ -356,8 +362,10 @@ export class ChannelController extends Controller {
356
362
  perform(action, payload = {}) {
357
363
  const seq = ++this.seq
358
364
  payload.hbk = seq
365
+ delete payload.hbt
359
366
  if (this.subscribed) {
360
- this.beginBusy(seq)
367
+ this.sendTrips(payload)
368
+ this.beginBusy(seq, action)
361
369
  // Subscription#perform returns false when the socket is not open —
362
370
  // the gap between the socket dying and the connection monitor
363
371
  // noticing, during which `subscribed` still says live. The frame
@@ -383,7 +391,8 @@ export class ChannelController extends Controller {
383
391
  // whole gap — the signal the connect window cannot give, where the
384
392
  // page is painted and looks live.
385
393
  if (!this.connectedOnce) {
386
- this.beginBusy(seq)
394
+ this.sendTrips(payload)
395
+ this.beginBusy(seq, action)
387
396
  this.queued.push([action, payload])
388
397
  return seq
389
398
  }
@@ -430,9 +439,11 @@ export class ChannelController extends Controller {
430
439
  // means the server has not answered, and a hold is motion after the
431
440
  // answer.
432
441
 
433
- beginBusy(seq) {
442
+ beginBusy(seq, action) {
434
443
  const record = {
435
444
  control: null,
445
+ action,
446
+ at: performance.now(),
436
447
  // Which render the island was on when this started, so the ack can
437
448
  // ask "has anything painted since?" without consulting a clock.
438
449
  renders: this.renders,
@@ -486,6 +497,7 @@ export class ChannelController extends Controller {
486
497
  if (this.state === "stalled") this.setState("ready")
487
498
  const record = this.busy.get(ack)
488
499
  if (!record) return
500
+ if (!dropped) record.ack = Math.round(performance.now() - record.at)
489
501
  if (dropped || this.renders > record.renders) return this.settle(ack)
490
502
  record.grace = setTimeout(() => this.settle(ack), this.constructor.busyGrace)
491
503
  }
@@ -499,6 +511,7 @@ export class ChannelController extends Controller {
499
511
  this.settledAt = performance.now()
500
512
  record.control?.removeAttribute("data-hibiki-busy")
501
513
  if (this.busy.size === 0) this.hideBusy()
514
+ if (record.ack !== undefined && this.constructor.reportTrips) this.keepTrip(record)
502
515
  }
503
516
 
504
517
  settleAll() {
@@ -528,13 +541,49 @@ export class ChannelController extends Controller {
528
541
  this.setState("stalled")
529
542
  }
530
543
 
544
+ // ── Trips ─────────────────────────────────────────────────────────────
545
+ //
546
+ // Only the browser sees both halves of a trip land: the ack on the
547
+ // island's socket and the render after its pubsub hop. With reportTrips
548
+ // on, each acked trip is kept as its action, the ms to the ack and the ms
549
+ // to the first render of its own (null when nothing rendered), and the
550
+ // next perform carries the last five under `hbt`. A dropped or stalled
551
+ // trip is not reported, and the last trip on a page never is.
552
+
553
+ keepTrip({ action, at, ack, paint }) {
554
+ this.trips.push({ action, at, ack, paint, settled: this.settledAt })
555
+ if (this.trips.length > 5) this.trips.shift()
556
+ }
557
+
558
+ sendTrips(payload) {
559
+ if (!this.trips.length) return
560
+ payload.hbt = this.trips.map(({ action, ack, paint }) => [action, ack, paint ?? null])
561
+ this.trips = []
562
+ }
563
+
564
+ // A render of this island's own has arrived. Timed now, not when it is
565
+ // applied: the order and motion holds come after the answer. A trip that
566
+ // settled a moment ago still takes it, by the rule own() uses.
567
+ arrived() {
568
+ const now = performance.now()
569
+ for (const record of this.busy.values()) record.paint ??= Math.round(now - record.at)
570
+ for (const trip of this.trips) {
571
+ if (trip.paint === undefined && now - trip.settled <= this.constructor.focusGrace) {
572
+ trip.paint = Math.round(now - trip.at)
573
+ }
574
+ }
575
+ }
576
+
531
577
  // Every inbound frame lands here first. Acks are transport bookkeeping,
532
578
  // handled by the base and deliberately NOT routed through received() — so
533
579
  // a subclass that overrides received without calling super still clears
534
580
  // its pending state.
535
581
  handleMessage(data) {
536
582
  if (data && "ack" in data) return this.acknowledge(data)
537
- if (data && data.html) this.renders++
583
+ if (data && data.html) {
584
+ this.renders++
585
+ this.arrived()
586
+ }
538
587
  this.received(data)
539
588
  }
540
589
 
@@ -855,6 +904,9 @@ export default class HibikiController extends ChannelController {
855
904
  this.streamRender = (event) => {
856
905
  const render = event.detail.render
857
906
  const stream = event.detail.newStream
907
+ if (stream?.getAttribute?.("data-hibiki-from") === `${this.channelValue}/${this.cidValue}`) {
908
+ this.arrived()
909
+ }
858
910
  // Taken now, not when the render's turn comes: see arrival().
859
911
  const arrival = this.arrival()
860
912
  event.detail.render = (streamElement) =>
@@ -13,14 +13,19 @@ module Hibiki
13
13
  # All private — a public helper would become a client-invocable action
14
14
  # (see the NOTE on Hibiki::Rails::Channel).
15
15
  module Broadcasts
16
+ include Instrumentation
17
+
16
18
  private
17
19
 
18
20
  # Replace the element with DOM id `target` (accepts partial:/locals:,
19
21
  # html:, or anything Turbo's renderer takes).
20
22
  def broadcast_replace(target:, attributes: {}, **rendering)
21
- Turbo::StreamsChannel.broadcast_replace_to(
22
- *stream_name, target:, attributes: attributes.merge(render_stamp), **rendering
23
- )
23
+ method = attributes[:method].to_s == "morph" ? :morph : :replace
24
+ instrument_render(target:, method:) do
25
+ Turbo::StreamsChannel.broadcast_replace_to(
26
+ *stream_name, target:, attributes: attributes.merge(render_stamp), **rendering
27
+ )
28
+ end
24
29
  end
25
30
 
26
31
  # Replace via Turbo 8 morphing (<turbo-stream action="replace"
@@ -32,7 +37,7 @@ module Hibiki
32
37
  # Tell the page to refresh itself (Turbo 8 morph-everything style).
33
38
  # Pair with Debounce when many writes should mean one refresh.
34
39
  def broadcast_refresh
35
- Turbo::StreamsChannel.broadcast_refresh_to(*stream_name)
40
+ instrument_render(method: :refresh) { Turbo::StreamsChannel.broadcast_refresh_to(*stream_name) }
36
41
  end
37
42
 
38
43
  # The Morph-everything style as one call: an effect that tracks
@@ -77,13 +77,20 @@ module Hibiki
77
77
  # produces zero bytes, and "clear on the next render" would hang
78
78
  # forever. `delete`, not `[]`: the key must not reach the action
79
79
  # method's data. No `hbk`, no ack, so a 0.3.0 page keeps working.
80
+ #
81
+ # `hbt` is the third reserved key: the trips the browser timed since
82
+ # its last action (see Instrumentation#trips_from). Deleted whether or
83
+ # not anyone listens, for the same reason as `hbk`.
80
84
  def perform_action(data)
81
85
  seq = data.delete("hbk")
86
+ trips = trips_from(data.delete("hbt"))
87
+ asked = Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
88
 
83
89
  # A nil actor means the subscription was rejected or already torn
84
90
  # down; drop the action rather than blow up the cable thread.
85
91
  posted = @__hibiki_actor&.post do
86
- Hibiki.batch { super(data) }
92
+ trips.each { instrument("trip", it) }
93
+ instrument_action(data, asked) { Hibiki.batch { super(data) } }
87
94
  ensure
88
95
  # Hibiki.batch flushes in its own ensure, so effects queued
89
96
  # before a raise have run by the time this does — the ack is
@@ -111,7 +118,9 @@ module Hibiki
111
118
  # The block runs owned + untracked (Hibiki.root); the effects it
112
119
  # creates do their dependency-collecting first run right here, on
113
120
  # the graph thread.
114
- @__hibiki_actor.post { @__hibiki_root = Hibiki.root { build_graph } }
121
+ @__hibiki_actor.post do
122
+ instrument("subscribe") { @__hibiki_root = Hibiki.root { build_graph } }
123
+ end
115
124
  Hibiki::Rails.registry.register(self)
116
125
  end
117
126
 
@@ -123,7 +132,7 @@ module Hibiki
123
132
  return unless @__hibiki_actor
124
133
 
125
134
  Hibiki::Rails.registry.unregister(self)
126
- @__hibiki_actor.post { @__hibiki_root.dispose }
135
+ @__hibiki_actor.post { instrument("unsubscribe") { @__hibiki_root.dispose } }
127
136
  @__hibiki_actor.stop
128
137
  end
129
138
 
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hibiki
4
+ module Rails
5
+ # The channel's ActiveSupport::Notifications events (`*.hibiki_rails`).
6
+ # Always on: with no subscriber `instrument` only yields. The gem
7
+ # subscribes to none of them. A subscriber runs on the graph thread, so
8
+ # it holds up that graph for as long as it takes.
9
+ #
10
+ # All private — a public helper would become a client-invocable action
11
+ # (see the NOTE on Hibiki::Rails::Channel).
12
+ module Instrumentation
13
+ # The most trips one action may report, and the longest time one may
14
+ # claim, in ms. The figures come from a browser, so they are bounded
15
+ # rather than believed.
16
+ TRIP_LIMIT = 5
17
+ TRIP_RANGE = (0..60_000)
18
+
19
+ EVENTS = %w[subscribe unsubscribe action render trip].to_h { [it, "#{it}.hibiki_rails"] }.freeze
20
+
21
+ private
22
+
23
+ # With nobody listening this is one lookup and a yield; no payload is
24
+ # built.
25
+ def instrument(event, payload = nil, &)
26
+ return (yield({}) if block_given?) unless listening?(event)
27
+
28
+ ActiveSupport::Notifications.instrument(EVENTS.fetch(event), { channel: self.class.name, **payload }, &)
29
+ end
30
+
31
+ # `asked` is the clock as the cable thread posted the action; `wait` is
32
+ # how long the graph thread took to reach it, in ms. An action the
33
+ # channel does not have gets no event: the name is the client's, and a
34
+ # subscriber that stores it must not be handed arbitrary strings.
35
+ def instrument_action(data, asked)
36
+ action = data["action"].to_s
37
+ return yield unless listening?("action") && self.class.action_methods.include?(action)
38
+
39
+ before = @__hibiki_render_count.to_i
40
+ instrument("action", { action:, wait: ms_since(asked) }) do |payload|
41
+ yield
42
+ ensure
43
+ payload[:renders] = @__hibiki_render_count.to_i - before
44
+ end
45
+ end
46
+
47
+ def listening?(event) = ActiveSupport::Notifications.notifier.listening?(EVENTS.fetch(event))
48
+
49
+ def ms_since(clock) = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - clock) * 1000
50
+
51
+ def instrument_render(method:, target: nil, &)
52
+ @__hibiki_render_count = @__hibiki_render_count.to_i + 1
53
+ instrument("render", { target:, method: }.compact, &)
54
+ end
55
+
56
+ # The trips a browser reported under `hbt`, as event payloads. Each is
57
+ # `[action, ack, paint]`: ms to the ack, and ms to the first render, or
58
+ # nil when nothing rendered.
59
+ def trips_from(reported)
60
+ reported.is_a?(Array) ? reported.first(TRIP_LIMIT).filter_map { trip(*it) } : []
61
+ end
62
+
63
+ def trip(action = nil, ack = nil, paint = nil, *)
64
+ return unless action.is_a?(String) && self.class.action_methods.include?(action)
65
+ return unless ack.is_a?(Numeric) && (paint.nil? || paint.is_a?(Numeric))
66
+
67
+ { action:, ack: ack.clamp(TRIP_RANGE), paint: paint&.clamp(TRIP_RANGE) }
68
+ end
69
+ end
70
+ end
71
+ 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 = __hibiki_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
- __hibiki_reset_nested(record)
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 nested associations
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
- __hibiki_reset_nested(record)
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
@@ -28,6 +28,13 @@ module Hibiki
28
28
  @lock.synchronize { @channels.size }
29
29
  end
30
30
 
31
+ # The live subscriptions by channel class name. Count from this rather
32
+ # than from subscribe and unsubscribe events: a development reload
33
+ # drops sockets without running `unsubscribed`.
34
+ def tally
35
+ @lock.synchronize { @channels.map { it.class.name }.tally }
36
+ end
37
+
31
38
  # Close each distinct cable connection, which routes every one of
32
39
  # its channels through the normal unsubscribed path — dispose root,
33
40
  # stop actor, unregister — so teardown has exactly ONE code path and
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hibiki
4
4
  module Rails
5
- VERSION = "0.17.1"
5
+ VERSION = "0.18.0"
6
6
  end
7
7
  end
data/lib/hibiki/rails.rb CHANGED
@@ -68,6 +68,7 @@ require_relative "rails/version"
68
68
  require_relative "rails/registry"
69
69
  require_relative "rails/graph_actor"
70
70
  require_relative "rails/debounce"
71
+ require_relative "rails/instrumentation"
71
72
  require_relative "rails/broadcasts"
72
73
  require_relative "rails/channel"
73
74
  require_relative "rails/nested_actions"
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.1
4
+ version: 0.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - planetaska
@@ -187,6 +187,7 @@ files:
187
187
  - lib/hibiki/rails/engine.rb
188
188
  - lib/hibiki/rails/graph_actor.rb
189
189
  - lib/hibiki/rails/helpers.rb
190
+ - lib/hibiki/rails/instrumentation.rb
190
191
  - lib/hibiki/rails/keys.rb
191
192
  - lib/hibiki/rails/nested_actions.rb
192
193
  - lib/hibiki/rails/reactive_form.rb
@@ -216,7 +217,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
216
217
  - !ruby/object:Gem::Version
217
218
  version: '0'
218
219
  requirements: []
219
- rubygems_version: 4.0.16
220
+ rubygems_version: 4.0.20
220
221
  specification_version: 4
221
222
  summary: 'Rails glue for hibiki: connection-scoped signal graphs over ActionCable
222
223
  + Turbo Streams.'