hibiki_rails 0.17.2 → 0.19.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 +83 -0
- data/README.md +46 -0
- data/app/assets/javascripts/hibiki.js +157 -16
- data/lib/hibiki/rails/broadcasts.rb +9 -4
- data/lib/hibiki/rails/channel.rb +43 -16
- data/lib/hibiki/rails/helpers.rb +7 -15
- data/lib/hibiki/rails/instrumentation.rb +71 -0
- data/lib/hibiki/rails/modifiers.rb +30 -0
- 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 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0ab077d178d71a16ddf8d0f5706179f7fbac93c4d851aae6e3ccda4ef0e4b674
|
|
4
|
+
data.tar.gz: 409e833560a3393d2ca3c0121c2e958f3d8bb986a0b62aaaed653fe0c0f06af3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 552f5913948b16d8ed030b1eb12c35f7f28f0f109f31807dbbaf86f9b2320c3929c959c2872800785b635b9c05229df0ebe5c218e4429b490442ee2cb81ba133
|
|
7
|
+
data.tar.gz: 0ff3466d160bd5074a8d02530c87222fd8dcc5aeed43b033fb8ff3549decd55423286e1757511d352f39a5df47a4d60198dd0efb9fa254479febf43f1ea7d8c6
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,89 @@ 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.19.0 — 2026-10-12
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
**History entries.** `transmit_url` takes `history: :push`, and each move of
|
|
12
|
+
the URL becomes an entry in the browser's history. Use it for state that
|
|
13
|
+
reads as a place, such as a wizard's step. The default, `history: :replace`,
|
|
14
|
+
is unchanged.
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
transmit_url(history: :push) { wizard_path(step: @step.value) }
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
**The `popstate` event.** `on(:visit, event: :popstate)` on the island root
|
|
21
|
+
answers Back and Forward over those entries. The client performs the action
|
|
22
|
+
with the query of the address the browser moved to.
|
|
23
|
+
|
|
24
|
+
```erb
|
|
25
|
+
<%= island WizardChannel, **on(:visit, event: :popstate) do |cid| %>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
def visit(data) = turn_to(data["step"])
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- The params come from the address bar. Check them as you would a subscribe
|
|
33
|
+
param.
|
|
34
|
+
- A pushed URL becomes an entry only when it answers an action of this tab.
|
|
35
|
+
The first URL a channel sends, and one that follows another session's
|
|
36
|
+
write, replace as before.
|
|
37
|
+
- If the action refuses the address, or changes nothing, the bar is put back
|
|
38
|
+
to the URL the channel last sent. No entry is added.
|
|
39
|
+
- The payload is the flat query. A key ending in `[]` arrives as an array.
|
|
40
|
+
- `popstate` takes no `debounce:` or `fallback:`.
|
|
41
|
+
- Without a `popstate` action, Back over a pushed entry reloads the page at
|
|
42
|
+
that address. The page shows the right state if it reads the same params
|
|
43
|
+
on its first render, as a mirrored URL already requires.
|
|
44
|
+
- Coming back to a pushed entry from another page also reloads it.
|
|
45
|
+
- A page that has pushed an entry is left out of Turbo's snapshot cache.
|
|
46
|
+
|
|
47
|
+
## 0.18.0 — 2026-10-11
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
**Instrumentation events.** A channel now publishes
|
|
52
|
+
`ActiveSupport::Notifications` events, so an app can time what a graph does.
|
|
53
|
+
Action Cable's own `perform_action.action_cable` covers only the hand-off to
|
|
54
|
+
the graph's thread; the action, its effects and its renders run after it.
|
|
55
|
+
|
|
56
|
+
| Event | Duration covers | Payload |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `subscribe.hibiki_rails` | building the graph and its first renders | `channel` |
|
|
59
|
+
| `unsubscribe.hibiki_rails` | disposing the graph | `channel` |
|
|
60
|
+
| `action.hibiki_rails` | the action and every effect it flushed | `channel`, `action`, `wait`, `renders` |
|
|
61
|
+
| `render.hibiki_rails` | one `broadcast_replace`, `broadcast_morph` or `broadcast_refresh` | `channel`, `target`, `method` |
|
|
62
|
+
|
|
63
|
+
- The events are always on and need no configuration. The gem subscribes to
|
|
64
|
+
none of them. With no subscriber an action costs one to two microseconds
|
|
65
|
+
more than in 0.17.2.
|
|
66
|
+
- A subscriber runs on the graph's thread, so keep it to cheap work in
|
|
67
|
+
memory.
|
|
68
|
+
- An action the channel does not have publishes nothing. A raising action
|
|
69
|
+
publishes, with the exception in the payload.
|
|
70
|
+
- `transmit_value` and `transmit_url` have no event of their own; Action
|
|
71
|
+
Cable's `transmit.action_cable` covers them.
|
|
72
|
+
|
|
73
|
+
**`Hibiki::Rails.registry.tally`** returns the live subscriptions by channel
|
|
74
|
+
class name.
|
|
75
|
+
|
|
76
|
+
**Trips: how long a visitor waited.** With `HibikiController.reportTrips =
|
|
77
|
+
true`, each island times its round trips and sends them with its next action.
|
|
78
|
+
The channel publishes one `trip.hibiki_rails` event for each, with `channel`,
|
|
79
|
+
`action`, `ack` (milliseconds until the server answered) and `paint`
|
|
80
|
+
(milliseconds until the first render arrived, or `nil`).
|
|
81
|
+
|
|
82
|
+
- Off by default. Set it once, on the class, for every island.
|
|
83
|
+
- A dropped or stalled trip is not reported, and neither is the last trip on
|
|
84
|
+
a page, since no action follows it.
|
|
85
|
+
- The figures come from the browser. The channel takes at most five per
|
|
86
|
+
action, only for its own actions, and clamps each to 0 to 60 000 ms.
|
|
87
|
+
- `hbt` joins `hbk` as a reserved payload key. The channel removes it before
|
|
88
|
+
the action method sees its data.
|
|
89
|
+
|
|
7
90
|
## 0.17.2 — 2026-10-11
|
|
8
91
|
|
|
9
92
|
### 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/>
|
|
@@ -93,7 +93,9 @@
|
|
|
93
93
|
//
|
|
94
94
|
// The left side of `->` is a hibiki event name: click, change, input,
|
|
95
95
|
// and submit are delegated DOM listeners; `visible` is a pseudo-event
|
|
96
|
-
// backed by an IntersectionObserver (the element entering the viewport)
|
|
96
|
+
// backed by an IntersectionObserver (the element entering the viewport);
|
|
97
|
+
// `popstate` is the window's, heard for the history entries a pushed
|
|
98
|
+
// `{ url }` frame made.
|
|
97
99
|
// Everything that is not "which event" is a sibling attribute, so the
|
|
98
100
|
// token grammar never has to grow.
|
|
99
101
|
//
|
|
@@ -185,6 +187,27 @@ let consumer
|
|
|
185
187
|
// island pins nothing even if disconnect never ran.
|
|
186
188
|
const islands = new WeakMap()
|
|
187
189
|
|
|
190
|
+
// History entries a pushed `{ url }` frame made, by the key in their state,
|
|
191
|
+
// and the one window listener that answers Back and Forward over them.
|
|
192
|
+
//
|
|
193
|
+
// Turbo hears popstate first, whatever the listener's phase: the browser
|
|
194
|
+
// runs a window's listeners in the order they were added. It answers an
|
|
195
|
+
// entry carrying its own state with a restore visit, and an entry without
|
|
196
|
+
// it by adopting it in place. So these entries carry only the key. The
|
|
197
|
+
// island that made one answers it (pop). An entry whose island has left
|
|
198
|
+
// the page, or declares no `popstate`, is loaded afresh: Turbo has by then
|
|
199
|
+
// filed a snapshot of the page on show under the entry's address, and a
|
|
200
|
+
// new document is the one thing that leaves none of that behind.
|
|
201
|
+
//
|
|
202
|
+
// Always listening, not from the first push: after a reload the browser
|
|
203
|
+
// still counts the entries the old document pushed as this document's, and
|
|
204
|
+
// answers Back over them with a popstate no island here made.
|
|
205
|
+
const pushers = new Map()
|
|
206
|
+
window.addEventListener("popstate", (event) => {
|
|
207
|
+
const key = event.state?.hibiki
|
|
208
|
+
if (key && !pushers.get(key)?.pop?.(event)) window.location.reload()
|
|
209
|
+
})
|
|
210
|
+
|
|
188
211
|
// The generic island CONTAINING element, or undefined. The walk performOn
|
|
189
212
|
// uses, exported for the motion module's `own` policy — which reads the
|
|
190
213
|
// island's trip records, so a ChannelController subclass (not in the map)
|
|
@@ -284,6 +307,10 @@ export class ChannelController extends Controller {
|
|
|
284
307
|
static busyCeiling = 10000
|
|
285
308
|
static focusGrace = 500
|
|
286
309
|
|
|
310
|
+
// Whether to tell the server how long each trip took (see "Trips"
|
|
311
|
+
// below). Off unless an app sets it, once, for every island.
|
|
312
|
+
static reportTrips = false
|
|
313
|
+
|
|
287
314
|
async connect() {
|
|
288
315
|
this.prepareTransport()
|
|
289
316
|
await this.openSubscription()
|
|
@@ -302,9 +329,13 @@ export class ChannelController extends Controller {
|
|
|
302
329
|
this.seq = 0
|
|
303
330
|
this.renders = 0
|
|
304
331
|
this.busy = new Map()
|
|
332
|
+
this.trips = []
|
|
305
333
|
this.settledAt = -Infinity
|
|
306
334
|
this.lastControl = null
|
|
307
335
|
this.queued = []
|
|
336
|
+
// Marks the history entries this island made; see replaceUrl().
|
|
337
|
+
this.historyKey = `${Date.now()}-${Math.random()}`
|
|
338
|
+
this.popping = undefined
|
|
308
339
|
this.setState("connecting")
|
|
309
340
|
}
|
|
310
341
|
|
|
@@ -334,6 +365,8 @@ export class ChannelController extends Controller {
|
|
|
334
365
|
|
|
335
366
|
disconnect() {
|
|
336
367
|
this.aborted = true
|
|
368
|
+
pushers.delete(this.historyKey)
|
|
369
|
+
this.popping = undefined // the bar is the next page's now
|
|
337
370
|
this.settleAll()
|
|
338
371
|
this.subscription?.unsubscribe()
|
|
339
372
|
this.subscription = undefined
|
|
@@ -345,7 +378,8 @@ export class ChannelController extends Controller {
|
|
|
345
378
|
// a sequence number under the reserved `hbk` key and opens a busy record
|
|
346
379
|
// that the matching ack closes; the seq is stamped LAST so a form field
|
|
347
380
|
// can never overwrite it. (`hbk` is the second reserved payload key —
|
|
348
|
-
// ActionCable's own Subscription#perform already writes `action`.
|
|
381
|
+
// ActionCable's own Subscription#perform already writes `action`. `hbt`
|
|
382
|
+
// is the third: the trips timed since the last perform.)
|
|
349
383
|
//
|
|
350
384
|
// Public API on the island controller (the header's "App JS reaching
|
|
351
385
|
// the graph"). Returns the seq when the action was accepted, so a
|
|
@@ -356,8 +390,10 @@ export class ChannelController extends Controller {
|
|
|
356
390
|
perform(action, payload = {}) {
|
|
357
391
|
const seq = ++this.seq
|
|
358
392
|
payload.hbk = seq
|
|
393
|
+
delete payload.hbt
|
|
359
394
|
if (this.subscribed) {
|
|
360
|
-
this.
|
|
395
|
+
this.sendTrips(payload)
|
|
396
|
+
this.beginBusy(seq, action)
|
|
361
397
|
// Subscription#perform returns false when the socket is not open —
|
|
362
398
|
// the gap between the socket dying and the connection monitor
|
|
363
399
|
// noticing, during which `subscribed` still says live. The frame
|
|
@@ -383,7 +419,8 @@ export class ChannelController extends Controller {
|
|
|
383
419
|
// whole gap — the signal the connect window cannot give, where the
|
|
384
420
|
// page is painted and looks live.
|
|
385
421
|
if (!this.connectedOnce) {
|
|
386
|
-
this.
|
|
422
|
+
this.sendTrips(payload)
|
|
423
|
+
this.beginBusy(seq, action)
|
|
387
424
|
this.queued.push([action, payload])
|
|
388
425
|
return seq
|
|
389
426
|
}
|
|
@@ -430,9 +467,11 @@ export class ChannelController extends Controller {
|
|
|
430
467
|
// means the server has not answered, and a hold is motion after the
|
|
431
468
|
// answer.
|
|
432
469
|
|
|
433
|
-
beginBusy(seq) {
|
|
470
|
+
beginBusy(seq, action) {
|
|
434
471
|
const record = {
|
|
435
472
|
control: null,
|
|
473
|
+
action,
|
|
474
|
+
at: performance.now(),
|
|
436
475
|
// Which render the island was on when this started, so the ack can
|
|
437
476
|
// ask "has anything painted since?" without consulting a clock.
|
|
438
477
|
renders: this.renders,
|
|
@@ -486,11 +525,13 @@ export class ChannelController extends Controller {
|
|
|
486
525
|
if (this.state === "stalled") this.setState("ready")
|
|
487
526
|
const record = this.busy.get(ack)
|
|
488
527
|
if (!record) return
|
|
528
|
+
if (!dropped) record.ack = Math.round(performance.now() - record.at)
|
|
489
529
|
if (dropped || this.renders > record.renders) return this.settle(ack)
|
|
490
530
|
record.grace = setTimeout(() => this.settle(ack), this.constructor.busyGrace)
|
|
491
531
|
}
|
|
492
532
|
|
|
493
533
|
settle(seq) {
|
|
534
|
+
if (this.popping === seq) this.popped()
|
|
494
535
|
const record = this.busy.get(seq)
|
|
495
536
|
if (!record) return
|
|
496
537
|
clearTimeout(record.ceiling)
|
|
@@ -499,6 +540,7 @@ export class ChannelController extends Controller {
|
|
|
499
540
|
this.settledAt = performance.now()
|
|
500
541
|
record.control?.removeAttribute("data-hibiki-busy")
|
|
501
542
|
if (this.busy.size === 0) this.hideBusy()
|
|
543
|
+
if (record.ack !== undefined && this.constructor.reportTrips) this.keepTrip(record)
|
|
502
544
|
}
|
|
503
545
|
|
|
504
546
|
settleAll() {
|
|
@@ -528,13 +570,49 @@ export class ChannelController extends Controller {
|
|
|
528
570
|
this.setState("stalled")
|
|
529
571
|
}
|
|
530
572
|
|
|
573
|
+
// ── Trips ─────────────────────────────────────────────────────────────
|
|
574
|
+
//
|
|
575
|
+
// Only the browser sees both halves of a trip land: the ack on the
|
|
576
|
+
// island's socket and the render after its pubsub hop. With reportTrips
|
|
577
|
+
// on, each acked trip is kept as its action, the ms to the ack and the ms
|
|
578
|
+
// to the first render of its own (null when nothing rendered), and the
|
|
579
|
+
// next perform carries the last five under `hbt`. A dropped or stalled
|
|
580
|
+
// trip is not reported, and the last trip on a page never is.
|
|
581
|
+
|
|
582
|
+
keepTrip({ action, at, ack, paint }) {
|
|
583
|
+
this.trips.push({ action, at, ack, paint, settled: this.settledAt })
|
|
584
|
+
if (this.trips.length > 5) this.trips.shift()
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
sendTrips(payload) {
|
|
588
|
+
if (!this.trips.length) return
|
|
589
|
+
payload.hbt = this.trips.map(({ action, ack, paint }) => [action, ack, paint ?? null])
|
|
590
|
+
this.trips = []
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// A render of this island's own has arrived. Timed now, not when it is
|
|
594
|
+
// applied: the order and motion holds come after the answer. A trip that
|
|
595
|
+
// settled a moment ago still takes it, by the rule own() uses.
|
|
596
|
+
arrived() {
|
|
597
|
+
const now = performance.now()
|
|
598
|
+
for (const record of this.busy.values()) record.paint ??= Math.round(now - record.at)
|
|
599
|
+
for (const trip of this.trips) {
|
|
600
|
+
if (trip.paint === undefined && now - trip.settled <= this.constructor.focusGrace) {
|
|
601
|
+
trip.paint = Math.round(now - trip.at)
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
531
606
|
// Every inbound frame lands here first. Acks are transport bookkeeping,
|
|
532
607
|
// handled by the base and deliberately NOT routed through received() — so
|
|
533
608
|
// a subclass that overrides received without calling super still clears
|
|
534
609
|
// its pending state.
|
|
535
610
|
handleMessage(data) {
|
|
536
611
|
if (data && "ack" in data) return this.acknowledge(data)
|
|
537
|
-
if (data && data.html)
|
|
612
|
+
if (data && data.html) {
|
|
613
|
+
this.renders++
|
|
614
|
+
this.arrived()
|
|
615
|
+
}
|
|
538
616
|
this.received(data)
|
|
539
617
|
}
|
|
540
618
|
|
|
@@ -546,7 +624,8 @@ export class ChannelController extends Controller {
|
|
|
546
624
|
// textContent assignment keeps values text-only and preserves each
|
|
547
625
|
// site's own tag/classes, so per-placeholder styling survives updates.
|
|
548
626
|
//
|
|
549
|
-
// { url } — mirror graph state into the address bar (transmit_url)
|
|
627
|
+
// { url } — mirror graph state into the address bar (transmit_url);
|
|
628
|
+
// with `push`, as a history entry.
|
|
550
629
|
//
|
|
551
630
|
// { html } — a fragment: swap it in by its root id. The swap is holdable
|
|
552
631
|
// the way Turbo's is: `hibiki:before-render` bubbles from the island
|
|
@@ -558,7 +637,7 @@ export class ChannelController extends Controller {
|
|
|
558
637
|
//
|
|
559
638
|
// Anything else is not ours to interpret. Subclasses may override, but
|
|
560
639
|
// should call super (or handle `value`) to keep reactive values live.
|
|
561
|
-
received({ html, value, url }) {
|
|
640
|
+
received({ html, value, url, push }) {
|
|
562
641
|
if (value) {
|
|
563
642
|
const selector = `[data-hibiki-value="${CSS.escape(value.name)}"]`
|
|
564
643
|
for (const site of document.querySelectorAll(selector)) {
|
|
@@ -566,7 +645,7 @@ export class ChannelController extends Controller {
|
|
|
566
645
|
}
|
|
567
646
|
return
|
|
568
647
|
}
|
|
569
|
-
if (url !== undefined) return this.replaceUrl(url)
|
|
648
|
+
if (url !== undefined) return this.replaceUrl(url, push)
|
|
570
649
|
if (!html) return
|
|
571
650
|
const template = document.createElement("template")
|
|
572
651
|
template.innerHTML = html
|
|
@@ -584,15 +663,48 @@ export class ChannelController extends Controller {
|
|
|
584
663
|
return event.detail.render()
|
|
585
664
|
}
|
|
586
665
|
|
|
587
|
-
// replaceState
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
|
|
666
|
+
// replaceState unless the channel asked for entries: the URL is a mirror
|
|
667
|
+
// of graph state, and Back leaves the page normally. Same-origin only, so
|
|
668
|
+
// a channel can move the bar solely within its own app (replaceState
|
|
669
|
+
// would throw on a cross-origin URL; refusing keeps it silent and
|
|
670
|
+
// intentional).
|
|
671
|
+
//
|
|
672
|
+
// A pushed URL becomes an entry only when it answers this tab's own
|
|
673
|
+
// action, so neither the first frame nor another session's write adds
|
|
674
|
+
// one, and never while a Back is being answered: an action that refuses
|
|
675
|
+
// the popped URL corrects the bar in place. The entry it leaves and the
|
|
676
|
+
// one it makes carry this island's key in place of Turbo's state; see
|
|
677
|
+
// `pushers`. The page is also kept out of Turbo's snapshot cache from
|
|
678
|
+
// then on, since Turbo no longer knows which address a snapshot is of.
|
|
679
|
+
replaceUrl(url, push = false) {
|
|
593
680
|
const resolved = new URL(url, window.location.href)
|
|
594
681
|
if (resolved.origin !== window.location.origin) return
|
|
595
|
-
|
|
682
|
+
this.url = resolved.href
|
|
683
|
+
if (!push || this.popping !== undefined || !this.own()) {
|
|
684
|
+
return history.replaceState(history.state, "", resolved)
|
|
685
|
+
}
|
|
686
|
+
if (resolved.href === window.location.href) return
|
|
687
|
+
pushers.set(this.historyKey, this)
|
|
688
|
+
window.Turbo?.cache?.exemptPageFromCache?.()
|
|
689
|
+
history.replaceState(this.entry(), "")
|
|
690
|
+
history.pushState(this.entry(), "", resolved)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// The state of an entry this island answers for: what is there now,
|
|
694
|
+
// less Turbo's part.
|
|
695
|
+
entry() {
|
|
696
|
+
const { turbo, ...rest } = history.state ?? {}
|
|
697
|
+
return { ...rest, hibiki: this.historyKey }
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// The Back being answered is over. An action that changed nothing sent no
|
|
701
|
+
// URL, so the bar still shows the popped one: put back the last the
|
|
702
|
+
// channel gave, which is the state the page is in.
|
|
703
|
+
popped() {
|
|
704
|
+
this.popping = undefined
|
|
705
|
+
if (this.url && this.url !== window.location.href) {
|
|
706
|
+
history.replaceState(history.state, "", this.url)
|
|
707
|
+
}
|
|
596
708
|
}
|
|
597
709
|
|
|
598
710
|
// `static channel = "..."` wins; otherwise infer Rails-style from the
|
|
@@ -684,6 +796,7 @@ const KEYS = {
|
|
|
684
796
|
}
|
|
685
797
|
const KEYED = '[data-hibiki-on*="keydown."]'
|
|
686
798
|
const HOTKEYED = '[data-hibiki-on*="@window"]'
|
|
799
|
+
const POPPED = '[data-hibiki-on*="popstate->"]'
|
|
687
800
|
const ISLAND = '[data-controller~="hibiki"]'
|
|
688
801
|
|
|
689
802
|
// A key press that is a gesture: not part of an IME composition (Safari
|
|
@@ -855,6 +968,9 @@ export default class HibikiController extends ChannelController {
|
|
|
855
968
|
this.streamRender = (event) => {
|
|
856
969
|
const render = event.detail.render
|
|
857
970
|
const stream = event.detail.newStream
|
|
971
|
+
if (stream?.getAttribute?.("data-hibiki-from") === `${this.channelValue}/${this.cidValue}`) {
|
|
972
|
+
this.arrived()
|
|
973
|
+
}
|
|
858
974
|
// Taken now, not when the render's turn comes: see arrival().
|
|
859
975
|
const arrival = this.arrival()
|
|
860
976
|
event.detail.render = (streamElement) =>
|
|
@@ -1166,6 +1282,31 @@ export default class HibikiController extends ChannelController {
|
|
|
1166
1282
|
}
|
|
1167
1283
|
}
|
|
1168
1284
|
|
|
1285
|
+
// Back or Forward landed on an entry this island made, or the one it
|
|
1286
|
+
// first pushed from: the control declaring `popstate`, the root as a
|
|
1287
|
+
// rule, performs its action with the entry's query. Turbo has adopted the
|
|
1288
|
+
// entry by now and written its own state there, which would make the
|
|
1289
|
+
// next visit to it a restore; the entry's own state goes back. False
|
|
1290
|
+
// when nothing here answers, and the page is loaded afresh instead.
|
|
1291
|
+
pop(event) {
|
|
1292
|
+
const control = [this.element, ...this.element.querySelectorAll(POPPED)].find(
|
|
1293
|
+
(element) => element.closest(ISLAND) === this.element && element.matches(POPPED)
|
|
1294
|
+
)
|
|
1295
|
+
const token = control?.dataset.hibikiOn.split(/\s+/).find((t) => t.startsWith("popstate->"))
|
|
1296
|
+
if (!token) return false
|
|
1297
|
+
history.replaceState(event.state, "")
|
|
1298
|
+
const payload = control.dataset.hibikiWith ? JSON.parse(control.dataset.hibikiWith) : {}
|
|
1299
|
+
const query = new URLSearchParams(window.location.search)
|
|
1300
|
+
for (const key of new Set(query.keys())) {
|
|
1301
|
+
const all = query.getAll(key)
|
|
1302
|
+
payload[payloadKey(key)] = key.endsWith("[]") ? all : all.at(-1)
|
|
1303
|
+
}
|
|
1304
|
+
this.flush()
|
|
1305
|
+
this.popping = this.perform(token.slice(10), payload)
|
|
1306
|
+
if (this.popping === undefined) this.popped() // offline: nothing will answer
|
|
1307
|
+
return true
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1169
1310
|
// A matched key is the control's gesture, so its default goes. A fallback
|
|
1170
1311
|
// control on an island that is not ready stands aside: a key has no
|
|
1171
1312
|
// native destination, but its default may be the form's own submit.
|
|
@@ -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
|
@@ -30,11 +30,23 @@ module Hibiki
|
|
|
30
30
|
# other public method would be picked up by Channel#action_methods and
|
|
31
31
|
# become client-invocable.
|
|
32
32
|
module Channel
|
|
33
|
+
# #transmit_url's history option, allowlisted so a typo names itself.
|
|
34
|
+
HISTORIES = %i[replace push].freeze
|
|
35
|
+
private_constant :HISTORIES
|
|
36
|
+
|
|
33
37
|
def self.included(base)
|
|
34
38
|
base.extend(ClassMethods)
|
|
35
39
|
base.include(Broadcasts)
|
|
36
40
|
end
|
|
37
41
|
|
|
42
|
+
# What a history mode adds to the `{ url: }` frame.
|
|
43
|
+
def self.url_frame(history)
|
|
44
|
+
return history == :push ? { push: true } : {} if HISTORIES.include?(history)
|
|
45
|
+
|
|
46
|
+
raise ArgumentError,
|
|
47
|
+
"transmit_url history #{history.inspect} must be one of #{HISTORIES.map(&:inspect).join(', ')}"
|
|
48
|
+
end
|
|
49
|
+
|
|
38
50
|
module ClassMethods
|
|
39
51
|
# Lifecycle hooks, never client-invocable actions. Performing
|
|
40
52
|
# "build_graph" would rebuild the graph and leak the old root;
|
|
@@ -77,13 +89,20 @@ module Hibiki
|
|
|
77
89
|
# produces zero bytes, and "clear on the next render" would hang
|
|
78
90
|
# forever. `delete`, not `[]`: the key must not reach the action
|
|
79
91
|
# method's data. No `hbk`, no ack, so a 0.3.0 page keeps working.
|
|
92
|
+
#
|
|
93
|
+
# `hbt` is the third reserved key: the trips the browser timed since
|
|
94
|
+
# its last action (see Instrumentation#trips_from). Deleted whether or
|
|
95
|
+
# not anyone listens, for the same reason as `hbk`.
|
|
80
96
|
def perform_action(data)
|
|
81
97
|
seq = data.delete("hbk")
|
|
98
|
+
trips = trips_from(data.delete("hbt"))
|
|
99
|
+
asked = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
82
100
|
|
|
83
101
|
# A nil actor means the subscription was rejected or already torn
|
|
84
102
|
# down; drop the action rather than blow up the cable thread.
|
|
85
103
|
posted = @__hibiki_actor&.post do
|
|
86
|
-
|
|
104
|
+
trips.each { instrument("trip", it) }
|
|
105
|
+
instrument_action(data, asked) { Hibiki.batch { super(data) } }
|
|
87
106
|
ensure
|
|
88
107
|
# Hibiki.batch flushes in its own ensure, so effects queued
|
|
89
108
|
# before a raise have run by the time this does — the ack is
|
|
@@ -111,7 +130,9 @@ module Hibiki
|
|
|
111
130
|
# The block runs owned + untracked (Hibiki.root); the effects it
|
|
112
131
|
# creates do their dependency-collecting first run right here, on
|
|
113
132
|
# the graph thread.
|
|
114
|
-
@__hibiki_actor.post
|
|
133
|
+
@__hibiki_actor.post do
|
|
134
|
+
instrument("subscribe") { @__hibiki_root = Hibiki.root { build_graph } }
|
|
135
|
+
end
|
|
115
136
|
Hibiki::Rails.registry.register(self)
|
|
116
137
|
end
|
|
117
138
|
|
|
@@ -123,7 +144,7 @@ module Hibiki
|
|
|
123
144
|
return unless @__hibiki_actor
|
|
124
145
|
|
|
125
146
|
Hibiki::Rails.registry.unregister(self)
|
|
126
|
-
@__hibiki_actor.post { @__hibiki_root.dispose }
|
|
147
|
+
@__hibiki_actor.post { instrument("unsubscribe") { @__hibiki_root.dispose } }
|
|
127
148
|
@__hibiki_actor.stop
|
|
128
149
|
end
|
|
129
150
|
|
|
@@ -168,24 +189,30 @@ module Hibiki
|
|
|
168
189
|
end
|
|
169
190
|
|
|
170
191
|
# Mirror graph state into the page's address bar: wraps the block in
|
|
171
|
-
# an effect that transmits `{ url: }
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
# the
|
|
182
|
-
|
|
192
|
+
# an effect that transmits `{ url: }`, and the packaged client moves
|
|
193
|
+
# the bar to it. Compute a same-origin path ("/songs?page=2" — the
|
|
194
|
+
# client refuses anything else), and omit params that hold their
|
|
195
|
+
# defaults, or the bar grows noise on the first transmit. Pair it with
|
|
196
|
+
# first-paint code that READS those params (controller +
|
|
197
|
+
# subscribe-params seeding), so the mirrored URL reloads to the state
|
|
198
|
+
# it names. Equality-gated like #transmit_value: re-runs track every
|
|
199
|
+
# signal read, re-sends only when the URL actually moved.
|
|
200
|
+
#
|
|
201
|
+
# history: :replace (the default) is replaceState: the URL is a MIRROR
|
|
202
|
+
# of state, not a history entry, so Back leaves the page normally.
|
|
203
|
+
# history: :push makes each move an entry, for state that reads as a
|
|
204
|
+
# place (a wizard's step). Back then needs an answer: put
|
|
205
|
+
# `on(:visit, event: :popstate)` on the island root, and the client
|
|
206
|
+
# performs that action with the popped URL's query params. They come
|
|
207
|
+
# from the address bar, so treat them like a subscribe param.
|
|
208
|
+
def transmit_url(history: :replace, &compute)
|
|
209
|
+
frame = Channel.url_frame(history)
|
|
183
210
|
last = Object.new
|
|
184
211
|
Hibiki::Effect.new do
|
|
185
212
|
url = compute.call.to_s
|
|
186
213
|
unless url == last
|
|
187
214
|
last = url
|
|
188
|
-
transmit({ url: })
|
|
215
|
+
transmit({ url: }.merge(frame))
|
|
189
216
|
end
|
|
190
217
|
end
|
|
191
218
|
end
|
data/lib/hibiki/rails/helpers.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "securerandom"
|
|
|
6
6
|
|
|
7
7
|
require_relative "dom_props"
|
|
8
8
|
require_relative "keys"
|
|
9
|
+
require_relative "modifiers"
|
|
9
10
|
|
|
10
11
|
module Hibiki
|
|
11
12
|
module Rails
|
|
@@ -187,6 +188,10 @@ module Hibiki
|
|
|
187
188
|
# `{ name => value }` — a checkbox its checked state, a multi-select
|
|
188
189
|
# its selected values — and a submitted form contributes its FormData.
|
|
189
190
|
#
|
|
191
|
+
# `event: :popstate` goes on the island root and answers Back and
|
|
192
|
+
# Forward over the entries `transmit_url(history: :push)` made; the
|
|
193
|
+
# payload is the popped URL's query.
|
|
194
|
+
#
|
|
190
195
|
# Everything else is a per-control modifier, kept out of the token
|
|
191
196
|
# grammar so the `->` left side stays purely "which event":
|
|
192
197
|
#
|
|
@@ -221,9 +226,10 @@ module Hibiki
|
|
|
221
226
|
action = Helpers.event_name(action)
|
|
222
227
|
events = Array(event).map { Helpers.event(it) }
|
|
223
228
|
debounce = DEFAULT_INPUT_DEBOUNCE if debounce.nil? && events.include?("input")
|
|
229
|
+
Modifiers.popstate!(debounce:, fallback:) if events.include?("popstate")
|
|
224
230
|
tokens = events.map { "#{it}->#{action}" }.join(" ")
|
|
225
231
|
{ data: { hibiki_on: tokens }
|
|
226
|
-
.merge(
|
|
232
|
+
.merge(Modifiers.attributes(with:, debounce:, confirm:, reset:, fallback:)) }
|
|
227
233
|
end
|
|
228
234
|
|
|
229
235
|
# Placeholder for a single reactive value: `<%= reactive :doubled, 0 %>`
|
|
@@ -305,20 +311,6 @@ module Hibiki
|
|
|
305
311
|
def island_stream(channel, cid, content)
|
|
306
312
|
safe_join([turbo_stream_from(channel.channel_name, cid), content])
|
|
307
313
|
end
|
|
308
|
-
|
|
309
|
-
# #on's per-control modifiers, kept out of the token grammar. Each is
|
|
310
|
-
# omitted when it matches the client's own default, so the common call
|
|
311
|
-
# still stamps exactly one attribute.
|
|
312
|
-
def on_modifiers(with:, debounce:, confirm:, reset:, fallback:)
|
|
313
|
-
{
|
|
314
|
-
hibiki_with: (JSON.generate(with) unless with.nil?),
|
|
315
|
-
# nonzero? returns the integer itself, so 0 compacts away.
|
|
316
|
-
hibiki_debounce: (Integer(debounce).nonzero? if debounce),
|
|
317
|
-
hibiki_confirm: confirm&.to_s,
|
|
318
|
-
hibiki_reset: ("false" if reset == false),
|
|
319
|
-
hibiki_fallback: ("true" if fallback)
|
|
320
|
-
}.compact
|
|
321
|
-
end
|
|
322
314
|
end
|
|
323
315
|
end
|
|
324
316
|
end
|
|
@@ -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
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Hibiki
|
|
6
|
+
module Rails
|
|
7
|
+
# Helpers#on's per-control modifiers, kept out of the token grammar.
|
|
8
|
+
module Modifiers
|
|
9
|
+
# The attributes beside hibiki_on. Each is omitted when it matches the
|
|
10
|
+
# client's own default, so the common call still stamps exactly one.
|
|
11
|
+
def self.attributes(with:, debounce:, confirm:, reset:, fallback:)
|
|
12
|
+
{
|
|
13
|
+
hibiki_with: (JSON.generate(with) unless with.nil?),
|
|
14
|
+
# nonzero? returns the integer itself, so 0 compacts away.
|
|
15
|
+
hibiki_debounce: (Integer(debounce).nonzero? if debounce),
|
|
16
|
+
hibiki_confirm: confirm&.to_s,
|
|
17
|
+
hibiki_reset: ("false" if reset == false),
|
|
18
|
+
hibiki_fallback: ("true" if fallback)
|
|
19
|
+
}.compact
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Back is not a gesture on a control: it has nothing to wait for and
|
|
23
|
+
# no native destination to fall back to.
|
|
24
|
+
def self.popstate!(**modifiers)
|
|
25
|
+
given = modifiers.compact.keys
|
|
26
|
+
raise ArgumentError, "a popstate event takes no #{given.join(' or ')}" if given.any?
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
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.19.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- planetaska
|
|
@@ -187,7 +187,9 @@ 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
|
|
192
|
+
- lib/hibiki/rails/modifiers.rb
|
|
191
193
|
- lib/hibiki/rails/nested_actions.rb
|
|
192
194
|
- lib/hibiki/rails/reactive_form.rb
|
|
193
195
|
- lib/hibiki/rails/registry.rb
|