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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83e9656d09f3910405a4db7167b526c173158eb1035d5cb5129e4f916c88c278
4
- data.tar.gz: e22b25605c6522e98cfdc86dcd685e62b3c60ed31ca865eb2daa53f74aaaf946
3
+ metadata.gz: 0ab077d178d71a16ddf8d0f5706179f7fbac93c4d851aae6e3ccda4ef0e4b674
4
+ data.tar.gz: 409e833560a3393d2ca3c0121c2e958f3d8bb986a0b62aaaed653fe0c0f06af3
5
5
  SHA512:
6
- metadata.gz: 8c459eb9453de6af88c6e7859849a41dcbf7c0daa5d2e79da12e5ac8f729f4b85093e9d10c176d97cb8cd5141ef47ffeb0bcb2482aa03b223e42b2af4b13d9a6
7
- data.tar.gz: 7ccd090ef5fdd72735d7bb45d356a1f7085fb6c323d42692c91b7226a5fd6ac058f0ea477efaa6d7693dfbfe66254d85c358e8ea5ca7d3fcd49f6fde56366040
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.beginBusy(seq)
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.beginBusy(seq)
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) this.renders++
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, never pushState: the URL is a mirror of graph state, not
588
- // a history entry — Back needs no popstate handling and leaves the page
589
- // normally. Same-origin only, so a channel can move the bar solely
590
- // within its own app (replaceState would throw on a cross-origin URL;
591
- // refusing keeps it silent and intentional).
592
- replaceUrl(url) {
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
- history.replaceState(history.state, "", resolved)
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
- 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
@@ -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
- Hibiki.batch { super(data) }
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 { @__hibiki_root = Hibiki.root { build_graph } }
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: }`; the packaged client
172
- # history.replaceState's the bar to it. replaceState, never pushState —
173
- # the URL is a MIRROR of state, not a history entry, so Back needs no
174
- # popstate choreography and leaves the page normally. Compute a
175
- # same-origin path ("/songs?page=2" — the client refuses anything
176
- # else), and omit params that hold their defaults, or the bar grows
177
- # noise on the first transmit. Pair it with first-paint code that READS
178
- # those params (controller + subscribe-params seeding), so the mirrored
179
- # URL reloads to the state it names. Equality-gated like
180
- # #transmit_value: re-runs track every signal read, re-sends only when
181
- # the URL actually moved.
182
- def transmit_url(&compute)
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
@@ -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(on_modifiers(with:, debounce:, confirm:, reset:, fallback:)) }
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hibiki
4
4
  module Rails
5
- VERSION = "0.17.2"
5
+ VERSION = "0.19.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.2
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