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 +4 -4
- data/CHANGELOG.md +81 -0
- data/README.md +46 -0
- data/app/assets/javascripts/hibiki.js +57 -5
- data/lib/hibiki/rails/broadcasts.rb +9 -4
- data/lib/hibiki/rails/channel.rb +12 -3
- data/lib/hibiki/rails/instrumentation.rb +71 -0
- data/lib/hibiki/rails/reactive_form.rb +37 -4
- data/lib/hibiki/rails/registry.rb +7 -0
- data/lib/hibiki/rails/version.rb +1 -1
- data/lib/hibiki/rails.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 915a98a11ca62685f60697d27383e8592978e0c2173b0e984c24eca7736cef55
|
|
4
|
+
data.tar.gz: 070cd14175c3027b164ec0b52378b59178499f3f3474fe3296722f6432c580ab
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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)
|
|
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
|
-
|
|
22
|
-
|
|
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
|
data/lib/hibiki/rails/channel.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
@@ -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
|
data/lib/hibiki/rails/version.rb
CHANGED
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.
|
|
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.
|
|
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.'
|