hibiki_rails 0.3.0 → 0.4.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +234 -0
  3. data/README.md +62 -0
  4. data/app/assets/javascripts/hibiki.js +244 -10
  5. data/lib/generators/hibiki/rails/css_variant.rb +189 -0
  6. data/lib/generators/hibiki/rails/generator_helpers.rb +20 -1
  7. data/lib/generators/hibiki/rails/island/island_generator.rb +0 -8
  8. data/lib/generators/hibiki/rails/scaffold/USAGE +32 -0
  9. data/lib/generators/hibiki/rails/scaffold/scaffold_generator.rb +73 -0
  10. data/lib/generators/hibiki/rails/scaffold_controller/USAGE +34 -0
  11. data/lib/generators/hibiki/rails/scaffold_controller/scaffold_controller_generator.rb +252 -0
  12. data/lib/generators/hibiki/rails/scaffold_controller/templates/daisyui/views/_pagination.html.erb.tt +72 -0
  13. data/lib/generators/hibiki/rails/scaffold_controller/templates/none/views/_pagination.html.erb.tt +60 -0
  14. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/channel.rb.tt +330 -0
  15. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/controller.rb.tt +77 -0
  16. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/form.rb.tt +40 -0
  17. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/member_channel.rb.tt +62 -0
  18. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/query.rb.tt +204 -0
  19. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/row.rb.tt +27 -0
  20. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_busy.html.erb.tt +103 -0
  21. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_controls.html.erb.tt +87 -0
  22. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_field_error.html.erb.tt +8 -0
  23. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_form.html.erb.tt +52 -0
  24. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_list.html.erb.tt +62 -0
  25. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row.html.erb.tt +67 -0
  26. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row_form.html.erb.tt +85 -0
  27. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/edit.html.erb.tt +13 -0
  28. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/index.html.erb.tt +47 -0
  29. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/new.html.erb.tt +12 -0
  30. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/show.html.erb.tt +27 -0
  31. data/lib/generators/hibiki/rails/scaffold_controller/templates/tailwind/views/_pagination.html.erb.tt +66 -0
  32. data/lib/generators/hibiki/rails/scaffold_helpers.rb +161 -0
  33. data/lib/generators/hibiki/rails/scaffold_model_injection.rb +102 -0
  34. data/lib/generators/hibiki/rails/scaffold_parent_injection.rb +185 -0
  35. data/lib/generators/hibiki/rails/scaffold_parent_notices.rb +96 -0
  36. data/lib/generators/hibiki/rails/scaffold_post_install.rb +176 -0
  37. data/lib/generators/hibiki/rails/scaffold_schema.rb +541 -0
  38. data/lib/generators/hibiki/rails/scaffold_view_helpers.rb +166 -0
  39. data/lib/hibiki/rails/channel.rb +29 -1
  40. data/lib/hibiki/rails/version.rb +1 -1
  41. metadata +37 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45614c5c98f4439de6f22da45a49d163ab7a06eccf53ed36108ee04d6b5c7f78
4
- data.tar.gz: c1dd28764cc7462dbb7ceaa5d0b76e1a0371bd8cc5523fe54545533d7b759c36
3
+ metadata.gz: 3b21e4afc4b4e74e30f3c32286a1a75b30052e55145d25210bb3cecfbb3bc24c
4
+ data.tar.gz: 5c26612918d2b81c3a6a6a134710331ba656b25e9441856627ddf6b3ee093e71
5
5
  SHA512:
6
- metadata.gz: 4908f9d337c86d575287ff2d21d3d74cd112334e893e8b042cb07e7e8a1de141ddd892b17b4b86d90ac2008ce8c7d9f4390fd1ebdd1cc1cafe88e75c6530c360
7
- data.tar.gz: 392976be271a02c7b5e354ec0d3e46117e1a8772cb0a68b99d4570f1f95309bc7abac3e0cd3ca38961eaea5eb849d4e4343effa55911629d367555949ac93408
6
+ metadata.gz: acf106a0cda97184c5bd9a988bab71efb8be65c76e1f5b08785f96795d7a9dc21f67a5511a9a10ffa7b5fee49d498891ce7516b926eec1868d76820fd7c42c48
7
+ data.tar.gz: 5e8d7b8aba5eaf643e5c6f82dcde5c824bd8f9eb1594163dd872bc0a304a6b37a796069385ec8fdc78e8bfe48dd7d9345d4f60a20b77fe3f72760d02e9b0157e
data/CHANGELOG.md ADDED
@@ -0,0 +1,234 @@
1
+ # Changelog
2
+
3
+ The gem and the npm package are released in lockstep and share these version
4
+ numbers — `app/assets/javascripts/hibiki.js` is a single copy served both ways,
5
+ so importmap and bundler apps always resolve identical client code.
6
+
7
+ ## 0.4.0 — 2026-08-03
8
+
9
+ ### Added
10
+
11
+ **Loading and connection state, stamped by the client.** The first growth of the
12
+ `data-hibiki-*` protocol since 0.3.0, and the first attributes in it that no Ruby
13
+ helper emits — the client writes them at runtime and app CSS reads them:
14
+
15
+ ```
16
+ island root data-hibiki-busy present while an action is in flight
17
+ aria-busy="true" the same fact, for assistive tech
18
+ data-hibiki-state connecting | ready | offline | stalled
19
+ firing control data-hibiki-busy on the control that started it
20
+ ```
21
+
22
+ Everything an app wants out of that is a descendant selector —
23
+ `[data-hibiki-busy] .spinner { display: inline-block }` — so per-row and
24
+ per-button feedback needs no server state and no `{#if loading}` branch. The
25
+ Ruby surface is unchanged: no new option on `on`, `hibiki_island` or `reactive`.
26
+
27
+ **Actions are acknowledged once their batch has run**, and the ack is what clears
28
+ the indicator. It cannot be "clear on the next render": the core's equality gate
29
+ (hibiki 0.2.0) lets an ordinary action produce zero bytes — paging to the page
30
+ you are already on, a search that does not change the query, a destroy of a row
31
+ another tab already deleted — so a render-based rule hangs forever on gestures
32
+ users make all the time. The ack sits in an `ensure`, so a raising action stops
33
+ the spinner too, and a subscription that has gone away acks `dropped: true` from
34
+ the cable thread instead, which is what lets the client tell "late" from "never".
35
+
36
+ A page running the 0.3.0 client sends no sequence number and gets no ack, so
37
+ nothing about this reaches an app that has not upgraded both halves.
38
+
39
+ **Actions performed before the subscription confirms are queued, not dropped.**
40
+ ActionCable's `Subscription#perform` silently returns false on a socket that is
41
+ not open yet, and on the Turbo-broadcast path that window is about three
42
+ serialised round trips — a click in it used to vanish. The queue covers the first
43
+ connect window only: after a reconnect the server rebuilds the graph with default
44
+ state, so replaying intent formed against the old one is worse than dropping it,
45
+ and the island reads `offline` for the whole gap instead.
46
+
47
+ Three class properties on `ChannelController` are the entire tuning surface —
48
+ `busyDelay` (150 ms before a trip is worth mentioning), `busyGrace` (60 ms for a
49
+ broadcast still in flight after its ack), `busyCeiling` (10 s before a trip is
50
+ declared stalled rather than cleared silently). Deliberately not Stimulus values
51
+ and not helper options; an app that wants different numbers subclasses and
52
+ re-registers.
53
+
54
+ The scaffold generators wire five sites to all of this — the counts line, the
55
+ pagination bar, the infinite-scroll sentinel, the destroy button and the
56
+ inline-edit Save — through a generated `_busy.html.erb` the app owns.
57
+
58
+ **`hibiki:rails:scaffold` and `hibiki:rails:scaffold_controller`** — a reactive
59
+ CRUD resource generated the way `rails g scaffold` generates a plain one.
60
+
61
+ ```sh
62
+ bin/rails g hibiki:rails:scaffold Book title:string author:references
63
+ bin/rails g hibiki:rails:scaffold_controller Book # an existing model
64
+ ```
65
+
66
+ The generated index is live: search, filter, sort and pagination are signal
67
+ state rather than page loads, rows edit in place, and a write from anywhere —
68
+ another tab, another user, the plain controller, a console — repaints every open
69
+ list. Everything is derived from the model's schema (columns, types, `belongs_to`
70
+ reflections, validators) or from the same `NAME field:type` argument list Rails'
71
+ own scaffold takes.
72
+
73
+ Stock `rails g scaffold` is untouched; these live under their own namespace.
74
+
75
+ Live per-field validation is derived only from rules the form can actually
76
+ evaluate before a round trip: presence, length, and numericality bounds that
77
+ carry no `allow_nil:`/`allow_blank:` exemption for the value in hand. A
78
+ validator gated on `if:`, `unless:` or `on:` depends on the record rather than
79
+ the field, so it contributes nothing live — it still runs at commit and still
80
+ lands in `#errors`, which the same per-field slots mirror. The clauses are
81
+ generated once; add validators and re-run `scaffold_controller` to pick them up.
82
+
83
+ **Field order is yours, and choosing it costs nothing.** With no field list the
84
+ columns follow the schema, which for an app built from `schema.rb` means
85
+ alphabetical. Passing them explicitly picks the order — and against a model that
86
+ already exists the generator still reads that model for everything else, so the
87
+ live clauses, a number field's `min:`/`max:` and a `belongs_to`'s display label
88
+ all survive the choice:
89
+
90
+ ```sh
91
+ bin/rails g hibiki:rails:scaffold_controller Author name:string bio:text age:integer
92
+ ```
93
+
94
+ A field the model has no column for is still generated, from the argument list
95
+ alone, and named in the post-install output — it may be a column whose migration
96
+ is still to come, and a silently missing field is the worse failure.
97
+
98
+ **A unique index with no uniqueness validator is called out.** A database
99
+ constraint is not a validator, and the generated form can only mirror what the
100
+ model checks — so without one, a duplicate raises `ActiveRecord::RecordNotUnique`
101
+ on the graph thread instead of showing a field error, and the round trip
102
+ completes having saved nothing. The generator names the column and the exact
103
+ `validates` line, for single and composite indexes alike.
104
+
105
+ Options: `--css=daisyui|tailwind|none` (detected when absent),
106
+ `--infinite-scroll`, `--skip-pagination`, `--skip-search`, `--page-size=N`,
107
+ `--skip-routes`.
108
+
109
+ Three notes on what it does to an app you already have.
110
+
111
+ **The model is modified** — one `delegate` per `belongs_to`, plus the
112
+ `after_commit` broadcast the whole thing hangs off.
113
+
114
+ **So is each model a `belongs_to` points at.** It gains the `has_many` half
115
+ Rails' own scaffold never writes (without it the generated destroy button raises
116
+ `InvalidForeignKey`) and a ping of its own, because a row prints the parent's
117
+ label rather than its id — rename an author and every open books index would
118
+ otherwise keep the old name. `dependent:` follows the association: `:destroy`
119
+ when it is required, `:nullify` when it is `optional: true`. That ping is
120
+ collection-grained, so an open *show* page keeps the old label until reload.
121
+
122
+ Both injections are idempotent and announced, and anything you already declared
123
+ is left alone — including a `dependent:` you chose yourself.
124
+
125
+ And **restart the server afterwards**: `app/forms/` is likely new, and Rails
126
+ computes autoload paths from the `app/*` glob at boot.
127
+
128
+ ### Fixed
129
+
130
+ **The model injection missed every namespaced model.** Thor anchors
131
+ `inject_into_class` on the class name as the file spells it, and the generator
132
+ passed the demodulized one — so `app/models/admin/book.rb`, which Rails writes as
133
+ `class Admin::Book < ApplicationRecord`, never matched. Silently: Thor rewrote
134
+ the file byte-identical and the generator reported a modification. The delegate
135
+ never landed, so the show page raised on arrival.
136
+
137
+ ## 0.3.0 — 2026-07-28
138
+
139
+ ### Security
140
+
141
+ **Channel lifecycle methods were client-invocable on Rails 7.1 and 7.2.**
142
+ Upgrade if you run hibiki_rails on either. Rails 8.x apps were never affected.
143
+
144
+ ActionCable builds a channel's client-invocable actions from the public methods
145
+ the class adds. The gem subtracted its lifecycle hooks through ActionCable's
146
+ `internal_methods` hook — but **that hook only exists on Rails 8.x**; on 7.1 and
147
+ 7.2 `action_methods` never consults it, so the override was silently inert and
148
+ the hooks stayed exposed.
149
+
150
+ Reachable by any client that can open a subscription, against its own
151
+ connection's graph. There is no cross-connection or cross-user data exposure;
152
+ the impact is resource exhaustion:
153
+
154
+ - `perform("build_graph")` — exposed on **every** affected app, since
155
+ `#build_graph` is always public. It re-runs the graph build outside
156
+ `Hibiki.root`, so the effects it creates are unowned and the dispose on
157
+ unsubscribe never reaches them, while the previous root is still held. Each
158
+ call leaks; repeated calls grow memory without bound.
159
+ - `perform("subscribed")` — exposed only where an app defined `#subscribed`
160
+ public, which the ActiveRecord guide's own `after_commit` example did until
161
+ this release. It allocates a second `GraphActor` — a new **thread** — and
162
+ overwrites the reference to the first, so the original is never stopped.
163
+ Repeated calls exhaust the process's threads.
164
+
165
+ Fixed by subtracting in `action_methods` itself, which works on every supported
166
+ version:
167
+
168
+ ```ruby
169
+ HIDDEN_ACTIONS = %w[build_graph subscribed unsubscribed].freeze
170
+ def action_methods = super - HIDDEN_ACTIONS
171
+ ```
172
+
173
+ No application change is required. Writing `#subscribed` and `#unsubscribed`
174
+ private is still the better habit, because it also protects the methods this
175
+ list does not know about — the guides now show them that way.
176
+
177
+ ### Added
178
+
179
+ - `on(action, event:)` takes an **event list**, so one element can answer
180
+ several events: `on(:load_more, event: %i[click visible])`.
181
+ - **`input`** joins the delegated events, with a per-control **`debounce:`**.
182
+ `:input` carries 250 ms unless told otherwise (`debounce: 0` opts out); the
183
+ value is stamped into the markup rather than being an invisible client
184
+ default.
185
+ - **`visible`**, a pseudo-event backed by an `IntersectionObserver`, so a
186
+ load-more control can double as an infinite-scroll sentinel. It fires once
187
+ per observation and re-attaches to the replacement element after each
188
+ fragment swap.
189
+ - **`confirm:`** on `on(...)` — a `window.confirm` gate. `data-turbo-confirm`
190
+ does nothing on a hibiki control, since it is not a Turbo-driven form.
191
+ - **`reset: false`** on a submit, to keep a form's inputs. The default resets
192
+ them synchronously, before the server has replied, which is right for an
193
+ "add" form and destroys an edit form's contents on a failed commit.
194
+ - **Subscribe params**: `hibiki_island(channel, cid:, params: { record_id: })`
195
+ reach the channel as `params[:key]`, which is how a channel learns which
196
+ record its page is about. They are client-supplied and untrusted — use one
197
+ only to look up a record inside a scope the channel chooses, and never
198
+ interpolate one into a streamable name. They cannot override `channel` or
199
+ `cid`.
200
+ - The client has its own test suite (`spec/js`, vitest + happy-dom) and a CI
201
+ job, plus a pinned Rails 8.1 matrix leg.
202
+
203
+ ### Changed
204
+
205
+ - **A changed checkbox now sends its checked state as a boolean**, and a
206
+ multi-select sends an array of its selected values. Previously both sent
207
+ `control.value` — the value *attribute* — so checking and unchecking a
208
+ checkbox produced byte-identical payloads. **This is a payload shape
209
+ change**: an action reading a `change` payload for a checkbox now receives
210
+ `true`/`false` rather than `"1"`. The form-submit path is unaffected.
211
+ - `transmit_value` is **equality-gated**: an effect re-runs whenever any signal
212
+ it read changed, so a bumped version token used to re-send every reactive
213
+ value's text even when byte-identical. The block still runs unconditionally,
214
+ so dependency collection is unchanged — only the transmit is skipped. If you
215
+ render a reactive placeholder *inside* a broadcast-replaced fragment, render
216
+ it with its current value: the swap resets the DOM text and the gate now
217
+ suppresses the re-send that used to heal it.
218
+ - Each graph job runs inside `Rails.application.executor`, so
219
+ `CurrentAttributes` are reset between jobs, autoloads are safe off the main
220
+ thread, and each job gets its own query cache.
221
+ - Graph-thread errors are **logged** in development and test.
222
+ `ActiveSupport::ErrorReporter` has no subscribers by default, so in a stock
223
+ app these previously vanished entirely — no line, no stack, just a fragment
224
+ that stopped updating. Production behaviour is unchanged.
225
+
226
+ ## 0.2.0 — 2026-07-21
227
+
228
+ - Reactive values: `reactive` / `reactive_attrs` / `transmit_value`, matched
229
+ document-wide so a value can render outside the island that computes it.
230
+ - `Components::` namespacing for the Phlex generator.
231
+
232
+ ## 0.1.0 — 2026-07-18
233
+
234
+ Initial release.
data/README.md CHANGED
@@ -59,6 +59,40 @@ bin/rails g hibiki:rails:stimulus counter static_pages
59
59
 
60
60
  This creates a minimal working reactive component in the given view path.
61
61
 
62
+ ### Generating a whole CRUD resource
63
+
64
+ For a full reactive resource — a live index with search, filtering, sorting and
65
+ pagination, plus edit-in-place — use the scaffold generators:
66
+
67
+ ```sh
68
+ # Model, migration, route and the reactive resource, like rails g scaffold
69
+ bin/rails g hibiki:rails:scaffold Book title:string author:references
70
+
71
+ # Or, for a model you already have — the schema is read for you
72
+ bin/rails g hibiki:rails:scaffold_controller Book
73
+
74
+ # Same, but you pick the field order; everything else still comes from the model
75
+ bin/rails g hibiki:rails:scaffold_controller Book title:string author:references
76
+ ```
77
+
78
+ Your plain `rails g scaffold` is untouched. The generated markup is styled to
79
+ match your app (DaisyUI, Tailwind, or unstyled — detected automatically, or
80
+ forced with `--css=`). Run `bin/rails g hibiki:rails:scaffold --help` for the
81
+ rest of the options.
82
+
83
+ Listing the fields yourself only chooses their order and which ones appear — the
84
+ model still answers everything else, so the live validation, a number field's
85
+ `min:`/`max:` and a `belongs_to`'s display label all survive the choice.
86
+
87
+ Your models are edited, not just read: the one being scaffolded gains the
88
+ `after_commit` broadcast that makes writes from anywhere reach an open list, and
89
+ each model a `belongs_to` points at gains the `has_many` half plus a ping of its
90
+ own, so renaming a parent repaints the lists that print its name. Both are
91
+ idempotent, announced, and leave anything you already declared alone.
92
+
93
+ Restart the server afterwards: `app/forms/` is new, and Rails works out its
94
+ autoload paths at boot.
95
+
62
96
  ### Render the reactive component
63
97
 
64
98
  The generated components are just Rails partials (or Phlex components, if you used the Phlex generator), so you can render one anywhere like any other partial:
@@ -73,6 +107,8 @@ Congratulations! Now you have your first reactive component!
73
107
 
74
108
  Documentation site: <https://planetaska.github.io/hibiki/rails-introduction/>
75
109
 
110
+ Release notes and upgrade advice: [CHANGELOG.md](CHANGELOG.md). **If you run Rails 7.1 or 7.2, read the 0.3.0 entry** — it fixes channel lifecycle methods that were client-invocable on those versions.
111
+
76
112
  ## Development
77
113
 
78
114
  ```
@@ -84,3 +120,29 @@ Both are what CI runs. The Ruby suite boots a minimal inline Rails app (`spec/su
84
120
 
85
121
  The gem and the npm package are **released in lockstep**: `app/assets/javascripts/hibiki.js` is the single copy — the engine puts it on the asset path and `package.json` points `main`/`module`/`exports` at it — so importmap and bundler apps must never be able to resolve different client code. Bump `lib/hibiki/rails/version.rb` and `package.json` in the same commit, and publish both. The version table lives in [the JS client docs](https://planetaska.github.io/hibiki/the-js-client/).
86
122
 
123
+ ## Contributing
124
+
125
+ Bug reports and pull requests are welcome at <https://github.com/planetaska/hibiki-rails>.
126
+
127
+ A few things that make a change easier to accept:
128
+
129
+ - **`bundle exec rake` and `bun run test` both green.** They are what CI runs, and the client half is easy to forget — most changes here touch one side, but the wire protocol is shared by both.
130
+ - **A regression spec first** for anything that was a bug. `spec/js/` covers the client, `spec/generators/` the generated output.
131
+ - **Generated code follows `rubocop-rails-omakase`**, which is what a stock Rails app lints with — not this gem's own style. The templates are written to satisfy the app's linter, not ours.
132
+ - **The `data-hibiki-*` attributes are a private contract** between the Ruby helpers and the vendored JS, and the two halves ship in one version — so a change to either side belongs in one commit with the other. Two of them, `data-hibiki-busy` and `data-hibiki-state`, are written by the client rather than by a helper, and apps select on them from CSS; those need a CHANGELOG entry even when no Ruby changes.
133
+
134
+ ### Running against a checkout
135
+
136
+ The gem side needs nothing special: point an app's Gemfile at your clone with `gem "hibiki_rails", path: "../hibiki-rails"`.
137
+
138
+ The npm side has one trap. `bun link` from an app resolves the linked package's own imports from the **symlink's realpath**, not from the app — so `hibiki.js` looks for `@rails/actioncable` inside your checkout rather than inside the app that linked it, and if the checkout has no `node_modules` the import fails at build time with nothing pointing at the cause.
139
+
140
+ ```sh
141
+ cd hibiki-rails && bun install # before linking, not after
142
+ ```
143
+
144
+ This affects only development against a clone. Anyone installing the published package resolves normally and never sees it.
145
+
146
+ ## License
147
+
148
+ [MIT](LICENSE.txt)
@@ -55,6 +55,20 @@
55
55
  // value sites data-hibiki-value="<name>" reactive-value placeholder;
56
56
  // the server's transmit_value message updates every match
57
57
  //
58
+ // The protocol also has a client-written half — the first attributes in it
59
+ // that no Ruby helper emits. These are stamped here at runtime and are
60
+ // read-only to app code; app CSS is their whole audience:
61
+ //
62
+ // island root data-hibiki-busy present while an action is in flight
63
+ // aria-busy="true" the same fact, for assistive tech
64
+ // data-hibiki-state connecting | ready | offline | stalled
65
+ // firing control data-hibiki-busy on the control that started it
66
+ //
67
+ // Everything the app wants out of that is a descendant selector —
68
+ // `[data-hibiki-busy] .spinner { display: inline-block }` — so per-row and
69
+ // per-button feedback needs no server state and no `{#if loading}` branch.
70
+ // Content is stale during a round trip, never absent.
71
+ //
58
72
  // The left side of `->` is a hibiki event name, of which DOM events are a
59
73
  // subset: click, change, input, submit are delegated listeners, and
60
74
  // `visible` is a pseudo-event backed by an IntersectionObserver (the
@@ -95,8 +109,53 @@ const controlValue = (control) => {
95
109
  export class ChannelController extends Controller {
96
110
  static values = { cid: String }
97
111
 
112
+ // Transport-state timings. Class properties on purpose: not Stimulus
113
+ // values and not helper options, so the Ruby surface stays unchanged and
114
+ // an island stamps nothing about them. An app that wants different
115
+ // numbers subclasses and re-registers.
116
+ //
117
+ // busyDelay ms before a round trip is worth mentioning. A localhost
118
+ // trip measures 18–25 ms, so 150 suppresses that flicker
119
+ // outright while a 150 ms-RTT link crosses it about exactly.
120
+ // Borrowed from Turbo's progress bar, which waits ~500 ms.
121
+ // busyGrace ms to wait after an ack for a Turbo render still in
122
+ // flight. The ack travels the island's own socket; a
123
+ // broadcast takes a pubsub hop, and measured against
124
+ // generated output the direct frame beats the paint by
125
+ // 3.3–3.6 ms. That gap belongs to the server and its
126
+ // backend, so it does not shrink on a fast link — and Redis
127
+ // widens it. Hence a bound in tens of ms, not single digits.
128
+ // busyCeiling ms before a trip is declared stalled rather than silently
129
+ // cleared. On a bad link "we lost it" beats "nothing
130
+ // happened".
131
+ static busyDelay = 150
132
+ static busyGrace = 60
133
+ static busyCeiling = 10000
134
+
98
135
  async connect() {
136
+ this.prepareTransport()
137
+ await this.openSubscription()
138
+ }
139
+
140
+ // The synchronous half of connect: everything a listener firing in the
141
+ // next millisecond depends on. HibikiController attaches its delegated
142
+ // listeners before openSubscription is even called, and the window
143
+ // before the subscription confirms is ~3 serialised round trips on the
144
+ // Turbo-broadcast path (Turbo's own subscribe, a SECOND websocket
145
+ // handshake, then ours) — tens of ms on localhost, about a second on a
146
+ // real remote link. Clicks in it used to vanish without a trace.
147
+ prepareTransport() {
99
148
  this.aborted = false
149
+ this.subscribed = false
150
+ this.connectedOnce = false
151
+ this.seq = 0
152
+ this.renders = 0
153
+ this.busy = new Map()
154
+ this.queued = []
155
+ this.setState("connecting")
156
+ }
157
+
158
+ async openSubscription() {
100
159
  consumer ??= createConsumer()
101
160
  this.defineForwarders()
102
161
  // Turbo-broadcast transport: wait for the element's own stream source
@@ -106,10 +165,11 @@ export class ChannelController extends Controller {
106
165
  const source = this.streamSource()
107
166
  if (source) await streamConnected(source)
108
167
  if (this.aborted) return // disconnected during the await
109
- this.subscription = consumer.subscriptions.create(
110
- this.subscribeParams(),
111
- { received: (data) => this.received(data) }
112
- )
168
+ this.subscription = consumer.subscriptions.create(this.subscribeParams(), {
169
+ received: (data) => this.handleMessage(data),
170
+ connected: () => this.linkOpened(),
171
+ disconnected: () => this.linkClosed()
172
+ })
113
173
  }
114
174
 
115
175
  // What identifies this subscription to the server. Override to add
@@ -120,15 +180,174 @@ export class ChannelController extends Controller {
120
180
 
121
181
  disconnect() {
122
182
  this.aborted = true
183
+ this.settleAll()
123
184
  this.subscription?.unsubscribe()
124
185
  this.subscription = undefined
186
+ this.subscribed = false
187
+ this.queued = []
125
188
  }
126
189
 
127
- // DOM → server: what declared action methods call. Optional chaining
128
- // because a debounced action can fire after disconnect, and a sentinel
129
- // can fire while connect is still awaiting its stream source.
190
+ // DOM → server: what declared action methods call. Every perform carries
191
+ // a sequence number under the reserved `hbk` key and opens a busy record
192
+ // that the matching ack closes; the seq is stamped LAST so a form field
193
+ // can never overwrite it. (`hbk` is the second reserved payload key —
194
+ // ActionCable's own Subscription#perform already writes `action`.)
195
+ //
196
+ // Returns the seq so a caller that knows which control fired can attach
197
+ // it; nobody has to.
130
198
  perform(action, payload = {}) {
131
- this.subscription?.perform(action, payload)
199
+ const seq = ++this.seq
200
+ payload.hbk = seq
201
+ if (this.subscribed) {
202
+ this.beginBusy(seq)
203
+ this.subscription.perform(action, payload)
204
+ return seq
205
+ }
206
+ // Queue rather than drop while the subscription is still coming up.
207
+ // ActionCable's Subscription#perform silently returns false on a socket
208
+ // that is not open yet, so this was a silent no-op for the whole
209
+ // connect window — and the trigger has to be the `connected` callback,
210
+ // not "the subscription object exists", because the second websocket's
211
+ // handshake sits between the two.
212
+ //
213
+ // Only that first window. Once the link has been up, a gap means the
214
+ // socket dropped, and reconnecting builds a FRESH graph server-side
215
+ // with default state — so replaying intent formed against the old one
216
+ // is worse than dropping it. The island is stamped `offline` for the
217
+ // whole gap, which is the signal the connect window cannot give: there
218
+ // the page is painted and looks live.
219
+ if (!this.connectedOnce) {
220
+ this.beginBusy(seq)
221
+ this.queued.push([action, payload])
222
+ }
223
+ return seq
224
+ }
225
+
226
+ // ActionCable's `connected`, i.e. the server confirmed the subscription.
227
+ // Fires again after every reconnect.
228
+ linkOpened() {
229
+ this.subscribed = true
230
+ this.connectedOnce = true
231
+ this.setState("ready")
232
+ const queued = this.queued
233
+ this.queued = []
234
+ for (const [action, payload] of queued) this.subscription.perform(action, payload)
235
+ }
236
+
237
+ linkClosed() {
238
+ this.subscribed = false
239
+ this.setState("offline")
240
+ // Deliberately NOT queued across the gap. A reconnect builds a fresh
241
+ // graph server-side with default state, so replaying intent formed
242
+ // against the old one is worse than dropping it. Outstanding records
243
+ // settle for the same reason: their acks are never coming.
244
+ this.queued = []
245
+ this.settleAll()
246
+ }
247
+
248
+ setState(state) {
249
+ this.state = state
250
+ this.element.setAttribute("data-hibiki-state", state)
251
+ }
252
+
253
+ // ── The busy machine ──────────────────────────────────────────────────
254
+ //
255
+ // A depth counter, not a boolean: typing while a page loads is one island
256
+ // with two actions outstanding. No requestAnimationFrame anywhere — it
257
+ // never fires in a background tab, which is exactly when a stuck
258
+ // indicator goes unnoticed.
259
+
260
+ beginBusy(seq) {
261
+ const record = {
262
+ control: null,
263
+ // Which render the island was on when this started, so the ack can
264
+ // ask "has anything painted since?" without consulting a clock.
265
+ renders: this.renders,
266
+ ceiling: setTimeout(() => this.stall(seq), this.constructor.busyCeiling)
267
+ }
268
+ this.busy.set(seq, record)
269
+ // Start the show-delay on 0→1 only: a second overlapping action must
270
+ // not restart it, because the user has been waiting since the first.
271
+ if (this.busy.size === 1) {
272
+ this.showTimer = setTimeout(() => this.showBusy(), this.constructor.busyDelay)
273
+ }
274
+ return record
275
+ }
276
+
277
+ // Attach the control that started a trip, so it can carry its own flag.
278
+ // Controls inside a server-replaced fragment are cleared by the swap
279
+ // itself (an idiomorph repaint syncs attributes, and the incoming HTML
280
+ // has none); controls outside it — the search field — need the removal
281
+ // in settle().
282
+ trackControl(seq, control) {
283
+ const record = this.busy.get(seq)
284
+ if (!record) return
285
+ record.control = control
286
+ if (this.busyShown) control.setAttribute("data-hibiki-busy", "")
287
+ }
288
+
289
+ showBusy() {
290
+ this.showTimer = undefined
291
+ this.busyShown = true
292
+ this.element.setAttribute("data-hibiki-busy", "")
293
+ this.element.setAttribute("aria-busy", "true")
294
+ for (const record of this.busy.values()) {
295
+ record.control?.setAttribute("data-hibiki-busy", "")
296
+ }
297
+ }
298
+
299
+ // The clear rule, and the whole design: a `dropped` ack settles at once
300
+ // because nothing is coming; a normal ack settles at once if a render has
301
+ // already landed, and otherwise waits out the grace window for one still
302
+ // in flight, then settles regardless — an action that legitimately
303
+ // rendered nothing is the ordinary case the ack exists for.
304
+ acknowledge({ ack, dropped }) {
305
+ // Any ack proves the link is alive again.
306
+ if (this.state === "stalled") this.setState("ready")
307
+ const record = this.busy.get(ack)
308
+ if (!record) return
309
+ if (dropped || this.renders > record.renders) return this.settle(ack)
310
+ record.grace = setTimeout(() => this.settle(ack), this.constructor.busyGrace)
311
+ }
312
+
313
+ settle(seq) {
314
+ const record = this.busy.get(seq)
315
+ if (!record) return
316
+ clearTimeout(record.ceiling)
317
+ clearTimeout(record.grace)
318
+ this.busy.delete(seq)
319
+ record.control?.removeAttribute("data-hibiki-busy")
320
+ if (this.busy.size === 0) this.hideBusy()
321
+ }
322
+
323
+ settleAll() {
324
+ for (const seq of [...this.busy.keys()]) this.settle(seq)
325
+ this.hideBusy() // an already-empty map still has a show timer to cancel
326
+ }
327
+
328
+ hideBusy() {
329
+ clearTimeout(this.showTimer)
330
+ this.showTimer = undefined
331
+ this.busyShown = false
332
+ this.element.removeAttribute("data-hibiki-busy")
333
+ this.element.removeAttribute("aria-busy")
334
+ }
335
+
336
+ // Say so rather than clearing silently: on a bad link the honest report
337
+ // is "we lost it", and a cleared indicator claims the opposite.
338
+ stall(seq) {
339
+ this.settle(seq)
340
+ this.setState("stalled")
341
+ }
342
+
343
+ // Every inbound frame lands here first. Acks are transport bookkeeping,
344
+ // handled by the base and deliberately NOT routed through received() — so
345
+ // a subclass that overrides received without calling super still clears
346
+ // its pending state.
347
+ handleMessage(data) {
348
+ if (data && "ack" in data) return this.acknowledge(data)
349
+ if (data && data.html) this.renders++
350
+ this.received(data)
132
351
  }
133
352
 
134
353
  // Server → DOM (transmit transport). Two message shapes:
@@ -228,6 +447,13 @@ export default class HibikiController extends ChannelController {
228
447
  }
229
448
 
230
449
  async connect() {
450
+ // Before the listeners, not after: a click delegated in the next
451
+ // millisecond reaches perform(), which needs the busy map and the
452
+ // queue to exist. This is also what stamps data-hibiki-state
453
+ // ="connecting" synchronously, so the island can be dimmed for the
454
+ // whole window rather than from the middle of it.
455
+ this.prepareTransport()
456
+
231
457
  // Root-scoped delegation (bound to the island, not document): controls
232
458
  // inside server-replaced fragments keep working with no rebinding.
233
459
  // Set up synchronously so disconnect can always tear them down.
@@ -267,12 +493,18 @@ export default class HibikiController extends ChannelController {
267
493
  const render = event.detail.render
268
494
  event.detail.render = async (streamElement) => {
269
495
  await render(streamElement)
496
+ // The Turbo transport's paint. Counting it here is what lets an ack
497
+ // settle immediately instead of waiting out its grace window. The
498
+ // listener is on the document, so an unrelated broadcast counts too
499
+ // — which at worst settles an already-acked trip a few ms early,
500
+ // never one that has not been acked at all.
501
+ this.renders++
270
502
  this.scanSentinels()
271
503
  }
272
504
  }
273
505
  document.addEventListener("turbo:before-stream-render", this.streamRender)
274
506
 
275
- await super.connect()
507
+ await this.openSubscription()
276
508
  if (this.aborted) return
277
509
  this.scanSentinels()
278
510
  }
@@ -349,7 +581,9 @@ export default class HibikiController extends ChannelController {
349
581
  } else if (control.name && (event.type === "change" || event.type === "input")) {
350
582
  payload[control.name] = controlValue(control)
351
583
  }
352
- this.perform(action, payload)
584
+ // perform stamps `hbk` after this merge, so a field literally named hbk
585
+ // loses to the seq rather than corrupting it.
586
+ this.trackControl(this.perform(action, payload), control)
353
587
  // Resetting is right for an "add" form and wrong for an edit one: it
354
588
  // runs synchronously, before the server has replied, so a failed commit
355
589
  // would discard what the user typed.