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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +234 -0
- data/README.md +62 -0
- data/app/assets/javascripts/hibiki.js +244 -10
- data/lib/generators/hibiki/rails/css_variant.rb +189 -0
- data/lib/generators/hibiki/rails/generator_helpers.rb +20 -1
- data/lib/generators/hibiki/rails/island/island_generator.rb +0 -8
- data/lib/generators/hibiki/rails/scaffold/USAGE +32 -0
- data/lib/generators/hibiki/rails/scaffold/scaffold_generator.rb +73 -0
- data/lib/generators/hibiki/rails/scaffold_controller/USAGE +34 -0
- data/lib/generators/hibiki/rails/scaffold_controller/scaffold_controller_generator.rb +252 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/daisyui/views/_pagination.html.erb.tt +72 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/none/views/_pagination.html.erb.tt +60 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/channel.rb.tt +330 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/controller.rb.tt +77 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/form.rb.tt +40 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/member_channel.rb.tt +62 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/query.rb.tt +204 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/row.rb.tt +27 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_busy.html.erb.tt +103 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_controls.html.erb.tt +87 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_field_error.html.erb.tt +8 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_form.html.erb.tt +52 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_list.html.erb.tt +62 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row.html.erb.tt +67 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row_form.html.erb.tt +85 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/edit.html.erb.tt +13 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/index.html.erb.tt +47 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/new.html.erb.tt +12 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/show.html.erb.tt +27 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/tailwind/views/_pagination.html.erb.tt +66 -0
- data/lib/generators/hibiki/rails/scaffold_helpers.rb +161 -0
- data/lib/generators/hibiki/rails/scaffold_model_injection.rb +102 -0
- data/lib/generators/hibiki/rails/scaffold_parent_injection.rb +185 -0
- data/lib/generators/hibiki/rails/scaffold_parent_notices.rb +96 -0
- data/lib/generators/hibiki/rails/scaffold_post_install.rb +176 -0
- data/lib/generators/hibiki/rails/scaffold_schema.rb +541 -0
- data/lib/generators/hibiki/rails/scaffold_view_helpers.rb +166 -0
- data/lib/hibiki/rails/channel.rb +29 -1
- data/lib/hibiki/rails/version.rb +1 -1
- metadata +37 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3b21e4afc4b4e74e30f3c32286a1a75b30052e55145d25210bb3cecfbb3bc24c
|
|
4
|
+
data.tar.gz: 5c26612918d2b81c3a6a6a134710331ba656b25e9441856627ddf6b3ee093e71
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
111
|
-
|
|
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.
|
|
128
|
-
//
|
|
129
|
-
//
|
|
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.
|
|
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
|
|
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
|
|
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.
|