hibiki_rails 0.16.0 → 0.17.1

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: d7bfeee9d4e44c3404ac6582548e0bdb46ae139cfa26eec2c71b7d8b95c40c77
4
- data.tar.gz: ab0505b42c8450e0a8014f94c380ed2f3e995448a9cf8cfc8ed16f4eb450d524
3
+ metadata.gz: 4919aaf1d8c96e1436138dfc5c0050602e0590447ccf9daebb5598072bf01e2d
4
+ data.tar.gz: 214048d87eec87fa482744750f736eb3d3004381864e69c12b1664a10ffaf9a5
5
5
  SHA512:
6
- metadata.gz: 6c032ed168913d5c4cc0a56afdf8b6a58cd89ad61581acfda17fc6e2a8030f0ebe4a81f35e0f4ec58691b79df2e6831be4bfa75f4d850db2ff334d9504a30bbb
7
- data.tar.gz: 79144e77c309815099be858e605ddad09c6a655ebc2b66f3e20c68fdfe6cf0dceca295bc418304ea6fb0865dfda732a9a4704018029fd8f52a39f21148e15532
6
+ metadata.gz: f1da64047eb007f11f45f55007519808ad717f9b53968273455586c3977486f1e03e2478f9041c05a7f9d889191d6591a7e116e21d918dfb7719b337e0eeed3b
7
+ data.tar.gz: 1ed922620989a1997ef4231a7c2e737cbca070ff3c3917966c74b1ed48c28e087e08fd7763152eeb96b713e8675b5126d831b07e5e4dc10e22c31d20cb908fdb
data/CHANGELOG.md CHANGED
@@ -4,6 +4,112 @@ The gem and the npm package are released in lockstep and share these version
4
4
  numbers — `app/assets/javascripts/hibiki.js` is a single copy served both ways,
5
5
  so importmap and bundler apps always resolve identical client code.
6
6
 
7
+ ## 0.17.1 — 2026-10-09
8
+
9
+ ### Fixed
10
+
11
+ **Renders sent over the broadcast transport could reach the page out of
12
+ order.** Action Cable hands each broadcast to a pool of threads, so two
13
+ renders a channel sent a millisecond apart could be delivered in either
14
+ order. When both drew the same target, the page ended on the older one and
15
+ stayed wrong until something rendered that target again: a Save button left
16
+ enabled, a note that contradicted the server's state. It was always
17
+ possible; since 0.17.0 sends a debounced field's text just ahead of a click
18
+ or a submit, any form with one did it routinely.
19
+
20
+ - `broadcast_replace` and `broadcast_morph` now number their renders, and
21
+ the client applies them in the order the channel sent them. Nothing changes
22
+ in the channel or the view.
23
+ - A render that arrives ahead of an earlier one waits for it, for at most
24
+ 100 ms. One that arrives later than that is still drawn, unless a newer
25
+ render has drawn the same target since.
26
+ - `broadcast_refresh`, the transmit transport, and streams an app sends with
27
+ `Turbo::StreamsChannel` itself are not affected and not changed.
28
+ - Both halves are needed, so update the gem and the npm package together. A
29
+ page on one side only behaves as 0.17.0 did.
30
+ - `broadcast_replace` and `broadcast_morph` also accept `attributes:`, which
31
+ are kept on the stream beside the gem's own.
32
+
33
+ ## 0.17.0 — 2026-10-08
34
+
35
+ ### Added
36
+
37
+ **Keys in `on`.** `event:` takes `keydown.<key>`, so a form's Escape or a
38
+ list's arrow keys reach the channel with no JavaScript in the app.
39
+
40
+ ```erb
41
+ <%= tag.div(**on(:cancel, event: "keydown.esc")) do %> ... <% end %>
42
+ <%= link_to "New", new_song_path,
43
+ **on(:new_form, event: ["click", "keydown.n@window"]) %>
44
+ ```
45
+
46
+ - A key is heard while focus is inside its element. With `@window` it is
47
+ heard anywhere on the page, for as long as the element is rendered.
48
+ - Keys: `enter`, `tab`, `esc`, `space`, `up`, `down`, `left`, `right`,
49
+ `home`, `end`, `page_up`, `page_down`, a letter, or a digit. Modifiers go
50
+ in front: `keydown.ctrl+s`, `keydown.meta+shift+k`. They are `ctrl`, `meta`
51
+ and `shift`, and they match exactly.
52
+ - A matched key's default is prevented. Keys pressed during IME composition
53
+ and a held key's repeats are ignored.
54
+ - A window key with neither `ctrl` nor `meta` is ignored while the visitor is
55
+ typing in a field, except `esc`.
56
+ - A bare `keydown` raises; `input` is the event for typed text.
57
+
58
+ **`focus`: the cursor in a field a render opens.** A row that turns into a
59
+ form used to leave focus on the page, so the visitor had to click into the
60
+ field before typing or pressing Escape.
61
+
62
+ ```erb
63
+ <%= text_field_tag "title", form.title, **focus(select: true) %>
64
+ ```
65
+
66
+ - The client focuses the marked element once, on the render that brings it.
67
+ `select: true` also selects its text.
68
+ - Only a render this tab's own action caused counts. Another visitor's save
69
+ never moves the cursor, and neither does a page refresh.
70
+ - A marked element in the page's first HTML is not focused; use `autofocus`
71
+ there.
72
+ - Focus is not taken from a field whose typed text has yet to be sent.
73
+ - `focus` returns a `{ data: }` hash like `on` and `props`:
74
+ `**on(:set_field, event: :input).deep_merge(focus)`.
75
+
76
+ **Focus comes back when a render removes the focused element.** After Save,
77
+ Cancel or Escape the form is gone, and focus used to fall to `<body>`: a
78
+ keyboard user lost their place, and the island stopped hearing keys. The
79
+ client now gives focus back to a control. Nothing more to write in the view.
80
+
81
+ - A form opened with `focus` returns to the control that opened it: the
82
+ row's Edit link, the New button. The client remembers which control's
83
+ action brought the marked field, and finds it again after a re-render by
84
+ its id, or by its action and `with:` payload.
85
+ - A replaced element with the same id takes the focus, and the caret, its
86
+ predecessor had.
87
+ - Failing both, focus goes to the first control inside the nearest ancestor
88
+ that has an id.
89
+ - Only a control ever takes focus. When none of these is found, as after a
90
+ row is destroyed, focus is left where the browser put it.
91
+ - It applies to any render, on both transports and after a page refresh, and
92
+ never moves focus the visitor has since put somewhere else.
93
+
94
+ ### Fixed
95
+
96
+ **Typed text is no longer lost to a render that lands inside the input
97
+ debounce.** A debounced field holds text the server has not heard for a
98
+ quarter second. Any render reaching the field in that wait was drawn without
99
+ it, and the morph wrote the older value over what the visitor had typed. The
100
+ render need not be theirs: another visitor's save of any row was enough.
101
+
102
+ - A field with unsent input now wins over a render. The client records the
103
+ text and the caret as it is typed and puts them back after the render,
104
+ until the debounced send has fired. After that the server's value wins
105
+ again, so Reset and a failed save still overwrite the field.
106
+ - Any action that is not debounced first sends the island's pending
107
+ debounced input, so gestures reach the server in the order they were made:
108
+ typing into a field and pressing Enter inside the wait no longer saves the
109
+ form without the last characters.
110
+ - This covers fields wired with a debounce (`event: :input`). Checkboxes,
111
+ radios, file inputs and multi-selects are not recorded.
112
+
7
113
  ## 0.16.0 — 2026-10-07
8
114
 
9
115
  ### Added
@@ -45,7 +45,10 @@
45
45
  // data-hibiki-params-value='{"record_id":7}' extra subscribe
46
46
  // params, merged UNDER channel/cid so they can't override them
47
47
  // controls data-hibiki-on="<event>-><action> ..." whitespace-separated;
48
- // e.g. "click->load_more visible->load_more"
48
+ // e.g. "click->load_more visible->load_more". A key is
49
+ // "keydown.<key>" (heard inside the control) or
50
+ // "keydown.<key>@window" (heard anywhere), with ctrl+ meta+
51
+ // shift+ in front in that order; only the names in KEYS below
49
52
  // data-hibiki-with='{"index":3}' optional JSON payload
50
53
  // data-hibiki-debounce="250" ms to let the gesture settle
51
54
  // data-hibiki-confirm="Are you sure?" window.confirm gate
@@ -61,6 +64,11 @@
61
64
  // that have no attribute, assigned on connect and after each
62
65
  // render; only the names in PROPS below, an optional "key"
63
66
  // beside them existing to change the text
67
+ // focus sites data-hibiki-focus="true|select" takes focus, and has its
68
+ // text selected, when a render this tab caused inserts it
69
+ // turbo streams data-hibiki-seq="3" the render's number, and
70
+ // data-hibiki-from="CounterChannel/<cid>" the island it is for;
71
+ // see "Render order" below
64
72
  //
65
73
  // The client writes the protocol's other half at runtime — read-only to
66
74
  // app code, addressed to app CSS:
@@ -89,6 +97,40 @@
89
97
  // Everything that is not "which event" is a sibling attribute, so the
90
98
  // token grammar never has to grow.
91
99
  //
100
+ // A debounced field holds text the server has not heard, and two rules keep
101
+ // it. A render landing inside the wait is drawn without the text, so the
102
+ // client records it at input time and puts it back after the render: a
103
+ // field with unsent input wins, until its send fires. And any undebounced
104
+ // action first flushes the island's pending debounces, so gestures reach
105
+ // the server in the order they were made.
106
+ //
107
+ // A focus site takes focus once, on the render that brings it: marked now
108
+ // and not at the scan before, by element or by id. Only a render this tab
109
+ // caused counts, one that arrived while an action was in flight or just
110
+ // after its ack, so a render nobody here asked for never moves the cursor.
111
+ // A mark in the page's first HTML is `autofocus`'s business, and a refresh
112
+ // morph focuses nothing.
113
+ //
114
+ // Focus is also given back. When a render removes the element that had
115
+ // focus, it would fall to <body>: a keyboard user loses their place, and
116
+ // the island stops hearing keys. So the client looks, in order, for the
117
+ // element now carrying the same id (a replaced field); for the control
118
+ // whose gesture opened the focus site that render closed (the Edit button
119
+ // of a form now gone), known again after a re-render by its id or by its
120
+ // action and payload; and for the first control of the focused element's
121
+ // nearest id-bearing ancestor. Only controls: when none of these is there,
122
+ // focus is left where the browser put it. Any render, whoever caused it.
123
+ //
124
+ // Render order. Action Cable hands each broadcast to a worker pool, so two
125
+ // renders the channel sent a millisecond apart can arrive in either order,
126
+ // and the page would end on the older one. The channel numbers its Turbo
127
+ // streams and the island applies them in that order: one that arrives ahead
128
+ // of its turn is held until the missing ones come, or until `orderGrace`
129
+ // runs out on one that never will. A render that turns up after it was
130
+ // given up on is still drawn, unless a later one has drawn the same target
131
+ // since. A stream with no number, or another island's, is left alone. The
132
+ // transmit transport needs none of this: it writes to the socket in order.
133
+ //
92
134
  // App JS reaching the graph — the ONE public seam. A gesture that needs
93
135
  // script (drag-and-drop, a third-party widget) fires its action through
94
136
  // the island's OWN subscription: `perform(action, payload)` on the island
@@ -232,9 +274,15 @@ export class ChannelController extends Controller {
232
274
  // busyCeiling ms before a trip is declared stalled rather than silently
233
275
  // cleared. On a bad link "we lost it" beats "nothing
234
276
  // happened".
277
+ // focusGrace ms after a trip settles in which a render still counts as
278
+ // its answer (see own()). One action can broadcast twice: the
279
+ // first render settles the trip with the ack and the second
280
+ // trails both. Longer than busyGrace because a missed focus
281
+ // costs more than a spinner's flicker.
235
282
  static busyDelay = 150
236
283
  static busyGrace = 60
237
284
  static busyCeiling = 10000
285
+ static focusGrace = 500
238
286
 
239
287
  async connect() {
240
288
  this.prepareTransport()
@@ -254,6 +302,7 @@ export class ChannelController extends Controller {
254
302
  this.seq = 0
255
303
  this.renders = 0
256
304
  this.busy = new Map()
305
+ this.settledAt = -Infinity
257
306
  this.lastControl = null
258
307
  this.queued = []
259
308
  this.setState("connecting")
@@ -447,6 +496,7 @@ export class ChannelController extends Controller {
447
496
  clearTimeout(record.ceiling)
448
497
  clearTimeout(record.grace)
449
498
  this.busy.delete(seq)
499
+ this.settledAt = performance.now()
450
500
  record.control?.removeAttribute("data-hibiki-busy")
451
501
  if (this.busy.size === 0) this.hideBusy()
452
502
  }
@@ -464,6 +514,13 @@ export class ChannelController extends Controller {
464
514
  this.element.removeAttribute("aria-busy")
465
515
  }
466
516
 
517
+ // Whether a render arriving now answers this tab's own action: one is in
518
+ // flight, or settled within the grace. Asked when the render arrives, not
519
+ // when it is applied, since the motion module may hold it for a second.
520
+ own() {
521
+ return this.busy.size > 0 || performance.now() - this.settledAt <= this.constructor.focusGrace
522
+ }
523
+
467
524
  // Say so rather than clearing silently: on a bad link the honest report
468
525
  // is "we lost it", and a cleared indicator claims the opposite.
469
526
  stall(seq) {
@@ -602,6 +659,88 @@ const PROPS = {
602
659
  }
603
660
  const appliedProps = new WeakMap()
604
661
 
662
+ // Fields whose state is not their `value`, so there is no typed text to keep.
663
+ const typed = (field) =>
664
+ typeof field.value === "string" &&
665
+ !field.multiple &&
666
+ !["checkbox", "radio", "file"].includes(field.type)
667
+
668
+ // The keys a `keydown.<key>` token may name, by the `event.key` each
669
+ // reports; a single letter or digit names itself. The server keeps the same
670
+ // list (Keys in the gem) and emits nothing outside it.
671
+ const KEYS = {
672
+ enter: "Enter",
673
+ tab: "Tab",
674
+ esc: "Escape",
675
+ space: " ",
676
+ up: "ArrowUp",
677
+ down: "ArrowDown",
678
+ left: "ArrowLeft",
679
+ right: "ArrowRight",
680
+ home: "Home",
681
+ end: "End",
682
+ page_up: "PageUp",
683
+ page_down: "PageDown"
684
+ }
685
+ const KEYED = '[data-hibiki-on*="keydown."]'
686
+ const HOTKEYED = '[data-hibiki-on*="@window"]'
687
+ const ISLAND = '[data-controller~="hibiki"]'
688
+
689
+ // A key press that is a gesture: not part of an IME composition (Safari
690
+ // reports the Enter that ends one only as keyCode 229), not a held key's
691
+ // repeat, and not Chrome's keyless autofill event.
692
+ const heard = (event) =>
693
+ typeof event.key === "string" &&
694
+ !event.isComposing &&
695
+ event.keyCode !== 229 &&
696
+ !event.repeat
697
+
698
+ // Where a plain key is text, or moves within it.
699
+ const NOT_TEXT = new Set([
700
+ "checkbox", "radio", "file", "button", "submit", "reset", "image", "range", "color"
701
+ ])
702
+ const entering = (target) =>
703
+ !!target.closest?.('textarea, select, [contenteditable]:not([contenteditable="false"])') ||
704
+ (target.localName === "input" && !NOT_TEXT.has(target.type))
705
+
706
+ // The action of the control's first key token of this scope ("" or
707
+ // "window") that the event matches. Modifiers match exactly, and Alt
708
+ // matches nothing: on macOS it changes the character the key reports.
709
+ // `typing` is a window key pressed in a text field, where only Escape and
710
+ // ctrl or meta combinations are not the field's own.
711
+ function keyAction(control, event, scope, typing = false) {
712
+ const pressed = event.key.length === 1 ? event.key.toLowerCase() : event.key
713
+ for (const token of (control.dataset.hibikiOn ?? "").split(/\s+/)) {
714
+ if (!token.startsWith("keydown.")) continue
715
+ const [bound, action] = token.slice(8).split("->")
716
+ const [combo, at = ""] = bound.split("@")
717
+ if (at !== scope) continue
718
+ const modifiers = combo.split("+")
719
+ const name = modifiers.pop()
720
+ if ((KEYS[name] ?? (name.length === 1 ? name : null)) !== pressed) continue
721
+ const ctrl = modifiers.includes("ctrl")
722
+ const meta = modifiers.includes("meta")
723
+ if (typing && !ctrl && !meta && name !== "esc") continue
724
+ if (
725
+ event.ctrlKey === ctrl &&
726
+ event.metaKey === meta &&
727
+ event.shiftKey === modifiers.includes("shift") &&
728
+ !event.altKey
729
+ ) {
730
+ return action
731
+ }
732
+ }
733
+ }
734
+
735
+ // What focus return may land on inside a surviving ancestor.
736
+ const FOCUSABLE =
737
+ 'a[href], button:not(:disabled), input:not(:disabled):not([type="hidden"]), ' +
738
+ 'select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
739
+
740
+ // Key events a window binding answered, so a second island declaring the
741
+ // same key still fires where a key taken by anything else is left alone.
742
+ const hotkeyed = new WeakSet()
743
+
605
744
  function applyProps(element) {
606
745
  const text = element.getAttribute("data-hibiki-props")
607
746
  let wanted
@@ -627,6 +766,11 @@ export default class HibikiController extends ChannelController {
627
766
  // stamps no data-hibiki-params-value.
628
767
  static values = { channel: String, params: Object }
629
768
 
769
+ // ms a Turbo render that arrived ahead of its turn waits for the ones
770
+ // before it (see inOrder). Only a lost broadcast waits it out; a late one
771
+ // is a pool hop behind, a millisecond or two.
772
+ static orderGrace = 100
773
+
630
774
  // The island stamps its channel; no inference.
631
775
  channelName() {
632
776
  return this.channelValue
@@ -663,12 +807,29 @@ export default class HibikiController extends ChannelController {
663
807
  this.element.addEventListener(type, handler)
664
808
  return [type, handler]
665
809
  })
810
+ // Keys take their own path: a key names its control by a token, not by
811
+ // being the nearest one.
812
+ const keydown = (event) => this.key(event)
813
+ this.element.addEventListener("keydown", keydown)
814
+ this.listeners.push(["keydown", keydown])
815
+ // On the window only while the island has a window key; see scan().
816
+ this.hotkeys = (event) => this.hotkey(event)
666
817
 
667
818
  // Debounce bookkeeping: a WeakMap keyed by control (so detached
668
- // elements don't pin memory) plus a flat set of live timeouts, which is
669
- // what disconnect can actually iterate.
819
+ // elements don't pin memory) plus the live timeouts with what each
820
+ // will run, which is what disconnect and flush can actually iterate.
821
+ // `unsent` is the text those timeouts have yet to send, by field.
670
822
  this.timers = new WeakMap()
671
- this.pending = new Set()
823
+ this.pending = new Map()
824
+ this.unsent = new Map()
825
+ // The focus sites seen at the last scan, each with the control whose
826
+ // gesture opened it, when one did; a site not among them is new.
827
+ // Seeded now, so a render landing in the connect window finds the
828
+ // page's own marks already seen.
829
+ this.marks = new Map()
830
+ for (const site of this.element.querySelectorAll("[data-hibiki-focus]")) {
831
+ if (site.closest(ISLAND) === this.element) this.marks.set(site, undefined)
832
+ }
672
833
 
673
834
  // `visible` is not a DOM event, so it needs its own observer beside the
674
835
  // delegated listeners. Always on, never a pluggable module: an
@@ -690,24 +851,38 @@ export default class HibikiController extends ChannelController {
690
851
  // Re-scan at the points a fragment can be swapped under us, rather
691
852
  // than blanket-observing the document: a MutationObserver over the page
692
853
  // is a real per-mutation cost paid by every app on it.
854
+ this.resetOrder()
693
855
  this.streamRender = (event) => {
694
856
  const render = event.detail.render
695
- event.detail.render = async (streamElement) => {
696
- await render(streamElement)
697
- // The Turbo transport's paint. Counting it here is what lets an ack
698
- // settle immediately instead of waiting out its grace window. The
699
- // listener is on the document, so an unrelated broadcast counts too
700
- // — which at worst settles an already-acked trip a few ms early,
701
- // never one that has not been acked at all.
702
- this.renders++
703
- this.scan()
704
- }
857
+ const stream = event.detail.newStream
858
+ // Taken now, not when the render's turn comes: see arrival().
859
+ const arrival = this.arrival()
860
+ event.detail.render = (streamElement) =>
861
+ this.inOrder(stream, async () => {
862
+ await render(streamElement)
863
+ // The Turbo transport's paint. Counting it here is what lets an ack
864
+ // settle immediately instead of waiting out its grace window. The
865
+ // listener is on the document, so an unrelated broadcast counts too
866
+ // — which at worst settles an already-acked trip a few ms early,
867
+ // never one that has not been acked at all.
868
+ this.renders++
869
+ this.scan(arrival)
870
+ })
705
871
  }
706
872
  document.addEventListener("turbo:before-stream-render", this.streamRender)
707
873
  // A refresh stream morphs the page after its own render has returned,
708
874
  // so the wrapper above scans too early for it; turbo:render follows
709
875
  // the morph.
710
- this.pageRender = () => this.scan()
876
+ // It is nobody's own render, so it brings no focus, but it can
877
+ // take the focused element away like any other.
878
+ this.pageBefore = () => {
879
+ this.paging = { trail: this.trail() }
880
+ }
881
+ this.pageRender = () => {
882
+ this.scan(this.paging)
883
+ this.paging = undefined
884
+ }
885
+ document.addEventListener("turbo:before-render", this.pageBefore)
711
886
  document.addEventListener("turbo:render", this.pageRender)
712
887
 
713
888
  await this.openSubscription()
@@ -721,34 +896,206 @@ export default class HibikiController extends ChannelController {
721
896
  this.element.removeEventListener(type, handler)
722
897
  }
723
898
  document.removeEventListener("turbo:before-stream-render", this.streamRender)
899
+ document.removeEventListener("turbo:before-render", this.pageBefore)
724
900
  document.removeEventListener("turbo:render", this.pageRender)
725
- for (const id of this.pending) clearTimeout(id)
901
+ window.removeEventListener("keydown", this.hotkeys)
902
+ for (const id of this.pending.keys()) clearTimeout(id)
726
903
  this.pending.clear()
904
+ this.unsent.clear()
905
+ this.marks.clear()
727
906
  this.observer.disconnect()
907
+ this.resetOrder()
728
908
  super.disconnect()
729
909
  }
730
910
 
911
+ // A reconnect builds a fresh graph, which numbers its renders from 1.
912
+ linkClosed() {
913
+ this.resetOrder()
914
+ super.linkClosed()
915
+ }
916
+
917
+ // ── Render order ──────────────────────────────────────────────────────
918
+ //
919
+ // `due` is the number of the next render to apply and `held` the renders
920
+ // waiting on it, by number. `missed` and `drawn` exist only after a gap
921
+ // was given up on: the numbers skipped, and the last number drawn into
922
+ // each target since, which is what tells a late render it is stale.
923
+
924
+ // Run `job` (the render and what follows it) when the stream's turn
925
+ // comes, or never when it is stale. Jobs are CALLED in order and not
926
+ // awaited: a call reaches the swap, or the motion module's queue, before
927
+ // it returns, and that queue keeps the order from there.
928
+ inOrder(stream, job) {
929
+ const seq = Number(stream?.getAttribute?.("data-hibiki-seq"))
930
+ if (!seq || stream.getAttribute("data-hibiki-from") !== `${this.channelValue}/${this.cidValue}`) {
931
+ return job()
932
+ }
933
+ const target = stream.getAttribute("target")
934
+ const run = () => {
935
+ this.drew(target, seq)
936
+ return job()
937
+ }
938
+ if (seq < this.due) {
939
+ // Late. A number never given up on is a repeat, or older than
940
+ // anything remembered.
941
+ const stale = !this.missed.delete(seq) || this.drawn.get(target) > seq
942
+ return stale ? Promise.resolve() : run()
943
+ }
944
+ if (seq > this.due) {
945
+ return new Promise((resolve) => {
946
+ this.held.set(seq, () => resolve(run()))
947
+ this.orderTimer ??= setTimeout(() => this.giveUp(), this.constructor.orderGrace)
948
+ })
949
+ }
950
+ const done = run()
951
+ this.due++
952
+ this.release()
953
+ return done
954
+ }
955
+
956
+ // Apply the held renders that now follow on. Any still held wait on a
957
+ // new gap, with a new grace.
958
+ release() {
959
+ for (let run; (run = this.held.get(this.due)); this.due++) {
960
+ this.held.delete(this.due)
961
+ run()
962
+ }
963
+ clearTimeout(this.orderTimer)
964
+ this.orderTimer = this.held.size
965
+ ? setTimeout(() => this.giveUp(), this.constructor.orderGrace)
966
+ : undefined
967
+ }
968
+
969
+ // The grace ran out: move past the missing numbers to the first held.
970
+ giveUp() {
971
+ const first = Math.min(...this.held.keys())
972
+ for (let seq = this.due; seq < first; seq++) this.missed.add(seq)
973
+ this.missedAt = performance.now()
974
+ this.due = first
975
+ this.release()
976
+ }
977
+
978
+ // Remember what was drawn while a missed render may still turn up, and
979
+ // no longer: one later than the ceiling is lost by the same measure a
980
+ // trip is.
981
+ drew(target, seq) {
982
+ if (performance.now() - this.missedAt > this.constructor.busyCeiling) this.missed.clear()
983
+ if (!this.missed.size) return this.drawn.clear()
984
+ if (!(this.drawn.get(target) > seq)) this.drawn.set(target, seq)
985
+ }
986
+
987
+ // Start counting again from 1. Renders still held are the old graph's
988
+ // last word, so they are applied first, in order.
989
+ resetOrder() {
990
+ clearTimeout(this.orderTimer)
991
+ this.orderTimer = undefined
992
+ const held = [...(this.held ?? [])].sort(([a], [b]) => a - b)
993
+ this.due = 1
994
+ this.held = new Map()
995
+ this.missed = new Set()
996
+ this.drawn = new Map()
997
+ for (const [, run] of held) run()
998
+ }
999
+
731
1000
  // The other swap point: hibiki's own transmit transport. A held swap
732
1001
  // hands back a promise; the replacement sentinel exists only after it.
733
1002
  received(data) {
1003
+ const arrival = this.arrival()
734
1004
  const swapped = super.received(data)
735
1005
  if (!data.html) return
736
- if (swapped?.then) swapped.then(() => this.scan())
737
- else this.scan()
738
- }
739
-
740
- // What a render leaves for the client to finish, in one walk of the
741
- // island: assign every prop site's properties, and observe every
742
- // `visible->` sentinel this island owns. observe() is a no-op for an
743
- // element already being observed, so re-scanning is cheap and cannot
744
- // double-fire a sentinel that merely stayed put. Props need no ownership
745
- // check: assigning one twice, from a nested island and its parent, lands
746
- // the same value.
747
- scan() {
1006
+ if (swapped?.then) swapped.then(() => this.scan(arrival))
1007
+ else this.scan(arrival)
1008
+ }
1009
+
1010
+ // What is only knowable as a render arrives: whether it answers this
1011
+ // tab's action, the control that action came from (the motion module
1012
+ // forgets it once a held render lands), and where focus was.
1013
+ arrival() {
1014
+ return { own: this.own(), control: this.lastControl, trail: this.trail() }
1015
+ }
1016
+
1017
+ // The focused element, when it is this island's, with the ids from it
1018
+ // upward, nearest first, and its caret.
1019
+ trail() {
1020
+ const element = document.activeElement
1021
+ if (!element || element === this.element || element.closest?.(ISLAND) !== this.element) return
1022
+ const ids = []
1023
+ for (let node = element; node !== this.element; node = node.parentElement) {
1024
+ if (node.id) ids.push(node.id)
1025
+ }
1026
+ if (this.element.id) ids.push(this.element.id)
1027
+ return { element, ids, self: Boolean(element.id), start: element.selectionStart, end: element.selectionEnd }
1028
+ }
1029
+
1030
+ // Give focus back after a render removed the element that had it; the
1031
+ // header has the rule. `opener` is the control behind a focus site the
1032
+ // same render closed. Left alone when focus has gone somewhere since.
1033
+ refocus({ element, ids, self, start, end }, opener) {
1034
+ if (element.isConnected) return
1035
+ const active = document.activeElement
1036
+ if ((active && active !== document.body && active.isConnected) || !this.element.isConnected) return
1037
+ const found = (id) => {
1038
+ const survivor = document.getElementById(id)
1039
+ return survivor && this.element.contains(survivor) ? survivor : null
1040
+ }
1041
+ const index = ids.findIndex(found)
1042
+ if (self && index === 0) {
1043
+ const twin = found(ids[0])
1044
+ twin.focus()
1045
+ if (start != null && document.activeElement === twin) twin.setSelectionRange?.(start, end)
1046
+ return
1047
+ }
1048
+ const candidates = [opener && this.twin(opener)]
1049
+ if (index === (self ? 1 : 0)) candidates.push(...found(ids[index]).querySelectorAll(FOCUSABLE))
1050
+ for (const control of candidates) {
1051
+ control?.focus()
1052
+ if (control && document.activeElement === control) return
1053
+ }
1054
+ }
1055
+
1056
+ // The control a gesture came from, or the one a render has put in its
1057
+ // place: the same id, else the same action and payload.
1058
+ twin({ element, id, on, payload }) {
1059
+ if (element.isConnected) return element
1060
+ const twin = id
1061
+ ? document.getElementById(id)
1062
+ : [...this.element.querySelectorAll("[data-hibiki-on]")].find(
1063
+ (control) => control.dataset.hibikiOn === on && control.dataset.hibikiWith === payload
1064
+ )
1065
+ return twin?.closest(ISLAND) === this.element ? twin : null
1066
+ }
1067
+
1068
+ // What a render leaves for the client to finish: put back unsent text,
1069
+ // then in one walk of the island assign every prop site's properties,
1070
+ // observe every `visible->` sentinel this island owns, listen on the
1071
+ // window while a window key is declared, and settle focus: on the first
1072
+ // focus site the render brought when it was this tab's `own`, else back
1073
+ // to a control when the render removed the focused element. Focus before
1074
+ // the unsent text, whose caret goes only to the focused field. observe()
1075
+ // is a no-op for an element already being observed, so re-scanning is
1076
+ // cheap and cannot double-fire a sentinel that merely stayed put; adding
1077
+ // a listener twice is a no-op too. Props need no ownership check: assigning one twice,
1078
+ // from a nested island and its parent, lands the same value. Nor does
1079
+ // the window listener, which finds nothing of its own to fire.
1080
+ scan({ own = false, control, trail } = {}) {
748
1081
  const found = this.element.querySelectorAll(
749
- '[data-hibiki-on*="visible->"], [data-hibiki-props]'
1082
+ `[data-hibiki-on*="visible->"], [data-hibiki-props], [data-hibiki-focus], ${HOTKEYED}`
750
1083
  )
1084
+ let hotkeys = this.element.matches(HOTKEYED)
1085
+ // A site is the one seen before when it is that element or, after a
1086
+ // replace, carries its id; it keeps the opener either way.
1087
+ const seen = new Map()
1088
+ for (const [was, opener] of this.marks) if (was.id) seen.set(was.id, opener)
1089
+ const marks = new Map()
1090
+ const ids = new Set()
1091
+ let site
751
1092
  for (const element of found) {
1093
+ if (element.hasAttribute("data-hibiki-focus") && element.closest(ISLAND) === this.element) {
1094
+ marks.set(element, this.marks.get(element) ?? seen.get(element.id))
1095
+ if (element.id) ids.add(element.id)
1096
+ if (!this.marks.has(element) && !seen.has(element.id)) site ??= element
1097
+ }
1098
+ hotkeys ||= element.dataset.hibikiOn?.includes("@window")
752
1099
  if (element.hasAttribute("data-hibiki-props")) applyProps(element)
753
1100
  if (
754
1101
  element.dataset.hibikiOn?.includes("visible->") &&
@@ -757,6 +1104,75 @@ export default class HibikiController extends ChannelController {
757
1104
  this.observer.observe(element)
758
1105
  }
759
1106
  }
1107
+ if (hotkeys) window.addEventListener("keydown", this.hotkeys)
1108
+ else window.removeEventListener("keydown", this.hotkeys)
1109
+ // The opener of a site this render closed, for the focus it leaves.
1110
+ let opener
1111
+ for (const [was, its] of this.marks) {
1112
+ if (its && !marks.has(was) && !ids.has(was.id)) opener = its
1113
+ }
1114
+ this.marks = marks
1115
+ // Not from a field with unsent input: the visitor is typing there.
1116
+ if (own && site && !this.unsent.has(trail?.element)) {
1117
+ if (control) {
1118
+ const { id, dataset } = control
1119
+ marks.set(site, { element: control, id, on: dataset.hibikiOn, payload: dataset.hibikiWith })
1120
+ }
1121
+ const take = () => {
1122
+ site.focus()
1123
+ if (site.dataset.hibikiFocus === "select") site.select?.()
1124
+ }
1125
+ take()
1126
+ // Turbo hands focus back to the id that had it before a stream, one
1127
+ // frame after appending it, which is just after this. Take it again.
1128
+ const id = trail?.element.id
1129
+ if (id) {
1130
+ setTimeout(() => {
1131
+ if (site.isConnected && document.activeElement?.id === id) take()
1132
+ }, 0)
1133
+ }
1134
+ } else if (trail) {
1135
+ this.refocus(trail, opener)
1136
+ }
1137
+ if (this.unsent.size) this.restoreUnsent()
1138
+ }
1139
+
1140
+ // A key pressed inside the island: the nearest control up from the
1141
+ // target whose key token matches. Not forward()'s nearest control, since
1142
+ // a field wired for `input` sits inside the form that takes Escape. A
1143
+ // key something else took (a nested island, the page's script) is left.
1144
+ key(event) {
1145
+ if (event.defaultPrevented || !heard(event)) return
1146
+ let control = event.target.closest?.(KEYED)
1147
+ while (control && this.element.contains(control)) {
1148
+ const action = control.closest(ISLAND) === this.element && keyAction(control, event, "")
1149
+ if (action) return this.press(control, event, action)
1150
+ control = control.parentElement?.closest(KEYED)
1151
+ }
1152
+ }
1153
+
1154
+ // A key pressed anywhere: the island's first enabled control declaring
1155
+ // it `@window`, the root included. One an inside control answered has
1156
+ // its default prevented, and so is not heard here.
1157
+ hotkey(event) {
1158
+ if ((event.defaultPrevented && !hotkeyed.has(event)) || !heard(event)) return
1159
+ const typing = entering(event.target)
1160
+ for (const control of [this.element, ...this.element.querySelectorAll(HOTKEYED)]) {
1161
+ if (control.closest(ISLAND) !== this.element || control.matches(":disabled")) continue
1162
+ const action = keyAction(control, event, "window", typing)
1163
+ if (!action) continue
1164
+ hotkeyed.add(event)
1165
+ return this.press(control, event, action)
1166
+ }
1167
+ }
1168
+
1169
+ // A matched key is the control's gesture, so its default goes. A fallback
1170
+ // control on an island that is not ready stands aside: a key has no
1171
+ // native destination, but its default may be the form's own submit.
1172
+ press(control, event, action) {
1173
+ if ("hibikiFallback" in control.dataset && this.state !== "ready") return
1174
+ event.preventDefault()
1175
+ this.run(control, event, action)
760
1176
  }
761
1177
 
762
1178
  // DOM → server: forward a control's event as a channel action.
@@ -776,8 +1192,11 @@ export default class HibikiController extends ChannelController {
776
1192
  .split(/\s+/)
777
1193
  .find((t) => t.startsWith(`${event.type}->`))
778
1194
  if (!token) return
779
- const action = token.slice(event.type.length + 2)
1195
+ this.run(control, event, token.slice(event.type.length + 2))
1196
+ }
780
1197
 
1198
+ // From a control's matched token to the wire.
1199
+ run(control, event, action) {
781
1200
  // A fallback control's native behavior IS the degraded path: unless
782
1201
  // the island is `ready`, stand aside — no perform, no queueing — and
783
1202
  // the browser follows the href or submits the form to its own
@@ -807,8 +1226,48 @@ export default class HibikiController extends ChannelController {
807
1226
  // keystroke that started the timer.
808
1227
  const wait = Number(control.dataset.hibikiDebounce)
809
1228
  const fire = () => this.send(control, event, action)
810
- if (wait > 0) this.debounce(control, action, wait, fire)
811
- else fire()
1229
+ if (wait > 0) {
1230
+ const field = event.target
1231
+ if (event.type !== "keydown" && typed(field)) {
1232
+ this.unsent.set(field, {
1233
+ value: field.value,
1234
+ start: field.selectionStart,
1235
+ end: field.selectionEnd
1236
+ })
1237
+ }
1238
+ this.debounce(control, action, wait, () => {
1239
+ fire()
1240
+ // Sent: from here the server's value for the field wins again.
1241
+ if (!this.timers.get(control)?.size) this.unsent.delete(field)
1242
+ })
1243
+ } else {
1244
+ this.flush()
1245
+ fire()
1246
+ }
1247
+ }
1248
+
1249
+ // A render drew these fields without the text typed since their last
1250
+ // send. A morph kept the element; a replace swapped in another with the
1251
+ // same id (the pending send still reads the detached one, whose value is
1252
+ // intact). The selection is the one the last keystroke left, restored
1253
+ // because assigning a value moves the caret to the end.
1254
+ restoreUnsent() {
1255
+ for (const [field, { value, start, end }] of this.unsent) {
1256
+ const live = field.isConnected ? field : field.id && document.getElementById(field.id)
1257
+ if (!live || !typed(live) || live.value === value) continue
1258
+ live.value = value
1259
+ if (start != null && live === document.activeElement) live.setSelectionRange(start, end)
1260
+ }
1261
+ }
1262
+
1263
+ // Run every pending debounce now, oldest first.
1264
+ flush() {
1265
+ const runs = [...this.pending]
1266
+ this.pending.clear()
1267
+ for (const [id, run] of runs) {
1268
+ clearTimeout(id)
1269
+ run()
1270
+ }
812
1271
  }
813
1272
 
814
1273
  send(control, event, action) {
@@ -865,13 +1324,14 @@ export default class HibikiController extends ChannelController {
865
1324
  clearTimeout(previous)
866
1325
  this.pending.delete(previous)
867
1326
  }
868
- const id = setTimeout(() => {
1327
+ const run = () => {
869
1328
  byAction.delete(action)
870
1329
  this.pending.delete(id)
871
1330
  fire()
872
- }, wait)
1331
+ }
1332
+ const id = setTimeout(run, wait)
873
1333
  byAction.set(action, id)
874
- this.pending.add(id)
1334
+ this.pending.set(id, run)
875
1335
  }
876
1336
  }
877
1337
 
@@ -17,16 +17,16 @@ module Hibiki
17
17
 
18
18
  # Replace the element with DOM id `target` (accepts partial:/locals:,
19
19
  # html:, or anything Turbo's renderer takes).
20
- def broadcast_replace(target:, **rendering)
21
- Turbo::StreamsChannel.broadcast_replace_to(*stream_name, target:, **rendering)
20
+ def broadcast_replace(target:, attributes: {}, **rendering)
21
+ Turbo::StreamsChannel.broadcast_replace_to(
22
+ *stream_name, target:, attributes: attributes.merge(render_stamp), **rendering
23
+ )
22
24
  end
23
25
 
24
26
  # Replace via Turbo 8 morphing (<turbo-stream action="replace"
25
27
  # method="morph">): keeps focus/scroll where a plain replace resets.
26
- def broadcast_morph(target:, **rendering)
27
- Turbo::StreamsChannel.broadcast_replace_to(
28
- *stream_name, target:, attributes: { method: :morph }, **rendering
29
- )
28
+ def broadcast_morph(target:, attributes: {}, **rendering)
29
+ broadcast_replace(target:, attributes: { method: :morph }.merge(attributes), **rendering)
30
30
  end
31
31
 
32
32
  # Tell the page to refresh itself (Turbo 8 morph-everything style).
@@ -47,6 +47,17 @@ module Hibiki
47
47
  broadcast_refresh
48
48
  end
49
49
  end
50
+
51
+ # The number each render carries, and the island it is for. Action
52
+ # Cable hands every broadcast to a worker pool, so two sent a
53
+ # millisecond apart can reach the page in either order; the client
54
+ # applies them by number. Counted per subscription from 1, on the
55
+ # graph thread alone. A refresh carries none: it fetches the page, so
56
+ # its order does not matter. Part of the private data-hibiki contract.
57
+ def render_stamp
58
+ @__hibiki_renders = @__hibiki_renders.to_i + 1
59
+ { "data-hibiki-seq": @__hibiki_renders, "data-hibiki-from": "#{self.class.name}/#{params[:cid]}" }
60
+ end
50
61
  end
51
62
  end
52
63
  end
@@ -5,6 +5,7 @@ require "json"
5
5
  require "securerandom"
6
6
 
7
7
  require_relative "dom_props"
8
+ require_relative "keys"
8
9
 
9
10
  module Hibiki
10
11
  module Rails
@@ -46,9 +47,8 @@ module Hibiki
46
47
 
47
48
  # Event and action names share the hibiki_on attribute, which is a
48
49
  # whitespace-separated token list — a name carrying a space or an
49
- # arrow would silently change what the client dispatches. The dot is
50
- # allowed so a future event qualifier (keydown.enter) needs no second
51
- # grammar change.
50
+ # arrow would silently change what the client dispatches. Key events
51
+ # (keydown.enter) have their own grammar; see Keys.
52
52
  EVENT_NAME = /\A[a-z][a-z0-9_.-]*\z/i
53
53
 
54
54
  # Per-keystroke round trips are the failure mode #on exists to avoid,
@@ -103,6 +103,13 @@ module Hibiki
103
103
  name
104
104
  end
105
105
 
106
+ # The left side of a token. Keys validates a key event, and anything
107
+ # else that asks for a scope.
108
+ def self.event(name)
109
+ name = name.to_s
110
+ name.match?(Keys::EVENT) || name.include?("@") ? Keys.token(name) : event_name(name)
111
+ end
112
+
106
113
  # The island root: one channel subscription per island, identified by
107
114
  # a per-page-load cid (each tab is its own graph). `channel` is the
108
115
  # channel class or its name as a string.
@@ -125,8 +132,7 @@ module Hibiki
125
132
  # override `channel` or `cid` through this hash.
126
133
  def hibiki_island(channel, cid:, params: nil)
127
134
  channel_name = channel.is_a?(Class) ? channel.name : channel.to_s
128
- data = { controller: "hibiki", hibiki_channel_value: channel_name,
129
- hibiki_cid_value: cid }
135
+ data = { controller: "hibiki", hibiki_channel_value: channel_name, hibiki_cid_value: cid }
130
136
  data[:hibiki_params_value] = JSON.generate(params) unless params.nil?
131
137
  { data: }
132
138
  end
@@ -169,6 +175,13 @@ module Hibiki
169
175
  #
170
176
  # on(:load_more, event: %i[click visible], with: { shown: rows.size })
171
177
  #
178
+ # A key is `keydown.<key>`, heard while focus is inside the element,
179
+ # or `keydown.<key>@window`, heard anywhere on the page while the
180
+ # element is rendered. Modifiers go in front; Keys lists both:
181
+ #
182
+ # on(:cancel, event: "keydown.esc")
183
+ # on(:new_row, event: ["click", "keydown.ctrl+n@window"])
184
+ #
172
185
  # `with:` is a hash sent as the action's payload. The client adds
173
186
  # event-derived data on top: a changed control contributes
174
187
  # `{ name => value }` — a checkbox its checked state, a multi-select
@@ -206,7 +219,7 @@ module Hibiki
206
219
  def on(action, event: :click, with: nil, debounce: nil, confirm: nil, reset: nil,
207
220
  fallback: nil)
208
221
  action = Helpers.event_name(action)
209
- events = Array(event).map { Helpers.event_name(it) }
222
+ events = Array(event).map { Helpers.event(it) }
210
223
  debounce = DEFAULT_INPUT_DEBOUNCE if debounce.nil? && events.include?("input")
211
224
  tokens = events.map { "#{it}->#{action}" }.join(" ")
212
225
  { data: { hibiki_on: tokens }
@@ -260,6 +273,18 @@ module Hibiki
260
273
  { data: { hibiki_props: JSON.generate(wanted) } }
261
274
  end
262
275
 
276
+ # Put the cursor in this element when a render opens it:
277
+ #
278
+ # tag.input name: "title", value: form.title, **focus(select: true)
279
+ #
280
+ # The client focuses it once, on the render that brings it, and only
281
+ # when that render answers this tab's own action; another visitor's
282
+ # save never moves the cursor. `select: true` also selects its text.
283
+ # An element in the page's first HTML is not focused: that is the
284
+ # `autofocus` attribute's job. Returns a `{ data: }` hash, merged like
285
+ # #props: `**on(:set_field, event: :input).deep_merge(focus)`.
286
+ def focus(select: false) = { data: { hibiki_focus: select ? "select" : "true" } }
287
+
263
288
  private
264
289
 
265
290
  # #island's preconditions: a block, an ActionView receiver, a channel
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hibiki
4
+ module Rails
5
+ # The key events Helpers#on may name, and their wire form:
6
+ # `keydown.[<modifier>+]*<key>[@window]`. The client keeps the same key
7
+ # list (KEYS in hibiki.js) and matches nothing outside it. Add a key in
8
+ # both places.
9
+ module Keys
10
+ # Named keys, Stimulus's spellings. Single letters and digits are
11
+ # keys too.
12
+ NAMES = %w[enter tab esc space up down left right home end page_up page_down].freeze
13
+ CHARACTER = /\A[a-z0-9]\z/
14
+
15
+ # In wire order. No alt: on macOS Option changes the character the
16
+ # key reports, so `alt+s` would never match there.
17
+ MODIFIERS = %w[ctrl meta shift].freeze
18
+
19
+ SCOPES = %w[window].freeze
20
+
21
+ # What Helpers#on hands over: `keydown` alone or followed by a
22
+ # qualifier, not a longer name that starts with it.
23
+ EVENT = /\Akeydown(?![a-z0-9_-])/i
24
+
25
+ # One `keydown...` event as its canonical token: lowercase, modifiers
26
+ # in MODIFIERS order.
27
+ def self.token(event)
28
+ event = event.to_s.downcase
29
+ unless EVENT.match?(event)
30
+ raise ArgumentError, "event #{event.inspect}: only a keydown.<key> event takes a scope"
31
+ end
32
+
33
+ combo, scope = event.split("@", 2)
34
+ *modifiers, key = combo.delete_prefix("keydown").delete_prefix(".").split("+")
35
+ key!(event, key)
36
+ modifiers!(event, key, modifiers)
37
+ scope!(event, scope)
38
+ ["keydown.#{[*(MODIFIERS & modifiers), key].join('+')}", scope].compact.join("@")
39
+ end
40
+
41
+ def self.key!(event, key)
42
+ if key.nil?
43
+ raise ArgumentError,
44
+ "#{event.inspect} names no key; write keydown.<key>, or use :input for typed text"
45
+ end
46
+ return if NAMES.include?(key) || CHARACTER.match?(key)
47
+
48
+ raise ArgumentError,
49
+ "key #{key.inspect} in #{event.inspect} must be a letter, a digit, or one of #{NAMES.join(', ')}"
50
+ end
51
+
52
+ def self.modifiers!(event, key, modifiers)
53
+ unknown = modifiers - MODIFIERS
54
+ unless unknown.empty?
55
+ raise ArgumentError,
56
+ "modifier #{unknown.first.inspect} in #{event.inspect} must be one of #{MODIFIERS.join(', ')}"
57
+ end
58
+ return unless modifiers.include?("shift") && key.match?(/\A\d\z/)
59
+
60
+ # Shift turns a digit into punctuation, so the key would never match.
61
+ raise ArgumentError, "#{event.inspect}: shift cannot be combined with a digit"
62
+ end
63
+
64
+ def self.scope!(event, scope)
65
+ return if scope.nil? || SCOPES.include?(scope)
66
+
67
+ raise ArgumentError, "scope #{scope.inspect} in #{event.inspect} must be #{SCOPES.join(', ')}"
68
+ end
69
+
70
+ private_class_method :key!, :modifiers!, :scope!
71
+ end
72
+ end
73
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hibiki
4
4
  module Rails
5
- VERSION = "0.16.0"
5
+ VERSION = "0.17.1"
6
6
  end
7
7
  end
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.16.0
4
+ version: 0.17.1
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/keys.rb
190
191
  - lib/hibiki/rails/nested_actions.rb
191
192
  - lib/hibiki/rails/reactive_form.rb
192
193
  - lib/hibiki/rails/registry.rb