hibiki_rails 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 73beec766683bf4a92c8bebcb19b62e58686f50f68e3b16f0a958c2d0d155de1
4
- data.tar.gz: 8ce7469020b298e4e94b7fa7daef8bafb5443a891fc90163339750f400aa9228
3
+ metadata.gz: 45614c5c98f4439de6f22da45a49d163ab7a06eccf53ed36108ee04d6b5c7f78
4
+ data.tar.gz: c1dd28764cc7462dbb7ceaa5d0b76e1a0371bd8cc5523fe54545533d7b759c36
5
5
  SHA512:
6
- metadata.gz: 3f5fc7f1fe7a4f72047d86e054612e9144e5815cc1a96d2fa87737573f9701ce2b2c6d713992822ee6cd90cae7b6cae5b4647bb6001b3f863dee2f38354ec131
7
- data.tar.gz: 67f0275c2e22bd291fc8940f84a99775c9e1acac5e3542ffc68edd9c896e26f9e786270904bfa75029e78c872984560fdeca6499532f4128c4f65a1c420ab7b2
6
+ metadata.gz: 4908f9d337c86d575287ff2d21d3d74cd112334e893e8b042cb07e7e8a1de141ddd892b17b4b86d90ac2008ce8c7d9f4390fd1ebdd1cc1cafe88e75c6530c360
7
+ data.tar.gz: 392976be271a02c7b5e354ec0d3e46117e1a8772cb0a68b99d4570f1f95309bc7abac3e0cd3ca38961eaea5eb849d4e4343effa55911629d367555949ac93408
data/README.md CHANGED
@@ -1,252 +1,86 @@
1
1
  # hibiki_rails
2
2
 
3
3
  Rails glue for [hibiki](https://github.com/planetaska/hibiki):
4
- connection-scoped signal graphs over ActionCable, pushing re-rendered HTML
5
- to the page — either through Turbo Streams, or over the channel's own
6
- subscription to the gem's packaged client (see "The packaged client").
4
+ connection-scoped signal graphs over ActionCable, pushing re-rendered HTML to the page — either through Turbo Streams, or over the channel's own subscription to the gem's packaged client (see [The JS client](https://planetaska.github.io/hibiki/the-js-client/)).
7
5
 
8
6
  ```
9
7
  cable action arrives → mutate signals → effects render partials →
10
8
  Turbo Streams broadcast → Turbo morphs the DOM
11
9
  ```
12
10
 
13
- A graph lives per cable connection (in practice: per browser tab), built
14
- when the channel subscribes and disposed when it unsubscribes. Effects
15
- subscribe to whatever signals they read; when an action writes a signal,
16
- exactly the affected effects re-render and broadcast.
11
+ A graph lives per cable connection (in practice: per browser tab), built when the channel subscribes and disposed when it unsubscribes. Effects subscribe to whatever signals they read; when an action writes a signal, exactly the affected effects re-render and broadcast.
17
12
 
18
- Incubating inside the hibiki repo while the core gem is pre-release; will
19
- be extracted to its own repository once hibiki 0.1.0 ships and this API
20
- stabilizes. Rails >= 8.0 (what's tested), Ruby >= 3.4.
13
+ Supports Rails >= 7.1, Ruby >= 3.4.
21
14
 
22
- ## Usage
15
+ ## Rails quick start
16
+
17
+ ### Installation
18
+
19
+ **Step 1** - Install the gem (`hibiki_rails` depends on the core `hibiki` gem)
23
20
 
24
21
  ```ruby
25
- class CounterChannel < ApplicationCable::Channel
26
- include Hibiki::Rails::Channel
27
-
28
- # Runs once on the graph's own thread, inside Hibiki.root.
29
- def build_graph
30
- @count = Hibiki::State.new(0)
31
- @step = Hibiki::State.new(1)
32
- doubled = Hibiki::Derived.new { @count.value * 2 }
33
-
34
- Hibiki::Effect.new do
35
- broadcast_replace target: "count", partial: "counter/count",
36
- locals: { count: @count.value, doubled: doubled.value }
37
- end
38
- Hibiki::Effect.new do
39
- broadcast_replace target: "step", partial: "counter/step",
40
- locals: { step: @step.value }
41
- end
42
- end
43
-
44
- # Actions are plain methods that touch signals directly: each one runs
45
- # on the graph thread inside one Hibiki.batch, so N writes still mean
46
- # one re-run per affected effect.
47
- def increment = @count.value += @step.value
48
- def burst = 10.times { @count.value += 1 }
49
- end
22
+ # Gemfile
23
+ gem "hibiki"
24
+ gem "hibiki_rails"
50
25
  ```
51
26
 
52
- The page supplies a per-page-load graph id (`cid`) and listens on the
53
- matching stream:
27
+ Or `gem install hibiki hibiki_rails`.
54
28
 
55
- ```erb
56
- <div data-controller="counter" data-counter-cid-value="<%= @cid %>">
57
- <%= turbo_stream_from "counter", @cid %>
58
- <%= render "count", count: 0, doubled: 0 %> <%# placeholder, see below %>
59
- ...
60
- </div>
61
- ```
29
+ **Step 2** - Run the install generator
62
30
 
63
- with `@cid = SecureRandom.uuid` in the controller action. The channel
64
- broadcasts to `[channel_name, cid]` — override `stream_name` (and/or
65
- `cid`) to derive identity differently.
66
-
67
- ## What the concern does
68
-
69
- - `subscribed` — rejects without a `cid` param, then runs `build_graph`
70
- inside `Hibiki.root` on a dedicated worker thread (a `GraphActor`).
71
- ActionCable dispatches on a thread pool with no per-channel ordering;
72
- hibiki's threading model is confinement — so cable threads only enqueue,
73
- and the graph lives on exactly one thread.
74
- - every action — the whole body is posted to that thread wrapped in one
75
- `Hibiki.batch`. `rescue_from` still applies (it runs on the graph
76
- thread); what it doesn't handle goes to `Rails.error` (source
77
- `"hibiki_rails"`).
78
- - `unsubscribed` — disposes the root (running `on_cleanup` hooks) and
79
- stops the worker, draining what was already queued.
80
- - dev reloading — an Engine hook disposes every live graph before code
81
- reloads (stale effects would run old class versions forever); cable
82
- clients auto-reconnect and rebuild. Graph state resets on reload, like
83
- any remount.
84
-
85
- ## Broadcast helpers
86
-
87
- Available inside effects (all bound to `stream_name`):
88
-
89
- - `broadcast_replace(target:, **rendering)` — `partial:`/`locals:`,
90
- `html:`, or anything Turbo's renderer accepts.
91
- - `broadcast_morph(target:, **rendering)` — replace via Turbo 8 morphing
92
- (keeps focus/scroll).
93
- - `broadcast_refresh` — tell the page to refresh itself.
94
- - `broadcast_refresh_effect(wait: 0.25) { ...read signals... }` — the
95
- morph-everything style: tracks whatever the block reads and answers
96
- changes with a debounced refresh, one per burst of actions rather than
97
- one per action.
98
-
99
- ## The packaged client
100
-
101
- The gem vendors its own JavaScript, turbo-rails-style: the engine puts
102
- `hibiki.js` on the app's asset path and merges the `"hibiki-rails"` pin into the
103
- import map, so importmap-rails apps have no install step beyond
104
- registering the controller — `bin/rails g hibiki:rails:install` does it
105
- (plus the `Helpers` include below, the `ApplicationCable` boilerplate,
106
- and the `@rails/actioncable` pin — a stock app has neither until its
107
- first `rails g channel`), or create the one-line shim yourself:
108
-
109
- ```js
110
- // app/javascript/controllers/hibiki_controller.js
111
- export { default } from "hibiki-rails" // registers as "hibiki" — the helpers hardcode that identifier
31
+ ```sh
32
+ bin/rails g hibiki:rails:install
112
33
  ```
113
34
 
114
- The registration is a file-backed shim on purpose: importmap apps
115
- eager-load it from the controllers directory, jsbundling apps get the
116
- matching import/register pair in `controllers/index.js` (the install
117
- generator appends it), and because it is derived from a real controller
118
- file, `bin/rails stimulus:manifest:update` regenerates it instead of
119
- dropping it.
35
+ The install generator detects whether your app uses an import map:
120
36
 
121
- (jsbundling/vite apps: `npm install hibiki-rails` — the [npm
122
- package](https://www.npmjs.com/package/hibiki-rails) is the same module the
123
- engine vendors, and pulls in `@rails/actioncable`; release in lockstep with
124
- the gem.)
37
+ - For importmap apps, installation is fully automatic — you are done.
38
+ - For apps with a JS bundler (esbuild, vite, bun, ...), also install the companion JS client (published as an npm package) with **one of**:
39
+ - `npm install hibiki-rails`
40
+ - `yarn add hibiki-rails`
41
+ - `bun add hibiki-rails`
42
+ - or the equivalent for your setup
125
43
 
126
- The client is one generic Stimulus controller that drives any *island*: a
127
- DOM subtree bound to one channel subscription. Islands are stamped with
128
- the opt-in `Hibiki::Rails::Helpers` — include it where you want the bare
129
- names (`ApplicationHelper` for ERB, individual Phlex components); the gem
130
- never includes it for you:
44
+ ### Using the generator
131
45
 
132
- ```erb
133
- <%= tag.div(**hibiki_island(TodosChannel, cid: @cid)) do %>
134
- <%= render TodoList.new %> <%# placeholder; replaced by DOM id %>
135
- <%= tag.form(**on(:add, event: :submit)) do %>
136
- <input type="text" name="title">
137
- <button>add</button>
138
- <% end %>
139
- <% end %>
140
- ```
46
+ You can create reactive components easily with the provided generators.
141
47
 
142
- - `hibiki_island(channel, cid:)` — the island root: one subscription,
143
- identified by the page's `cid`.
144
- - `on(action, event:, with:)` — forward a DOM event (`:click` default,
145
- `:change`, `:submit`) as a channel action, with `with:` as its payload.
146
- A changed control also sends `{ name => value }`; a submitted form sends
147
- its FormData and is reset after performing.
48
+ Create your first reactive component by running:
148
49
 
149
- Transport is the channel's own subscription in both directions: render
150
- effects call `transmit({ html: })` and the client swaps each fragment in
151
- by its root DOM id (`Hibiki::Phlex.render_effect` pairs naturally):
50
+ ```sh
51
+ # Replace [your_view_path] with your desired view path,
52
+ # e.g. counters, posts, users/profile...
53
+ bin/rails g hibiki:rails:stimulus counter [your_view_path]
152
54
 
153
- ```ruby
154
- def build_graph
155
- @list = TodoList.new
156
- Hibiki::Phlex.render_effect(@list) { |html| transmit({ html: }) }
157
- end
55
+ # For example, this creates "counter" component partials
56
+ # inside app/views/static_pages
57
+ bin/rails g hibiki:rails:stimulus counter static_pages
158
58
  ```
159
59
 
160
- Because the client registers its `received` callback at subscribe time —
161
- before the server ever runs `build_graph` — the effects' first transmits
162
- always land: no Turbo stream, no connected-wait, and the server-rendered
163
- initial HTML is only a paint-avoidance placeholder. One rule carries over
164
- from any replace-fragment design: never transmit a fragment containing
165
- the input the user is currently typing in.
166
-
167
- The `data-hibiki-*` attributes the helpers emit are a private contract
168
- with the vendored JS — they version together; don't hand-write them in
169
- app code. The protocol itself is Stimulus-free (Stimulus only hosts the
170
- controller lifecycle), so a hand-rolled client can drive the same
171
- attributes: `toy-phlex/` in the parent repo does it in ~40 lines. The
172
- helper interface's shape is inspired by
173
- [phlex-reactive](https://phlex-reactive.zoolutions.llc)'s `on(...)`
174
- actions.
175
-
176
- ## Generators
177
-
178
- Each supported shape has a generator that scaffolds it as a *working*
179
- mini-example — one state, one derived, one action, one effect; run it,
180
- render the output from any page, click `+1`, watch it live-update — meant
181
- to be reshaped in place, not filled in from scratch:
60
+ This creates a minimal working reactive component in the given view path.
182
61
 
183
- ```sh
184
- bin/rails g hibiki:rails:install # one-time wiring: register line,
185
- # Helpers include, ApplicationCable
186
- # boilerplate + actioncable pin
187
- # (idempotent)
188
- bin/rails g hibiki:rails:stimulus NAME [VIEW_PATH] # channel + ChannelController
189
- # subclass + view partial
190
- bin/rails g hibiki:rails:island NAME [VIEW_PATH] # channel + helpers-stamped view
191
- # partial, no per-component JS
192
- bin/rails g hibiki:rails:phlex NAME # channel + Phlex component +
193
- # island wrapper (needs hibiki_phlex)
194
- ```
62
+ ### Render the reactive component
63
+
64
+ 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:
195
65
 
196
- `VIEW_PATH` is the views directory under `app/views` (defaults to `NAME`);
197
- the emitted partial is self-contained (`cid` defaults to a per-render
198
- uuid), so `<%= render "counter/counter" %>` — or `<%= render
199
- CounterIsland.new %>` for the Phlex shape — is the only line a page needs.
200
- The `stimulus` shape works with zero wiring; `island` and `phlex` need the
201
- one-time `hibiki:rails:install` (they print a hint when it's missing).
202
- Namespaced names work (`admin/counter` pins `static channel` where the
203
- Stimulus identifier can't infer it). In apps without an importmap
204
- (jsbundling/vite), where `controllers/index.js` has no eager loader, the
205
- `stimulus` generator also appends the controller's import/register pair
206
- to it — the same lines `stimulus:manifest:update` would emit.
207
-
208
- ## The initial-state pattern (Turbo transport)
209
-
210
- Islands on the Turbo-broadcast transport instead (the "Usage" example
211
- above) have an ordering problem the transmit transport doesn't: the
212
- graph's effects do their first run inside `subscribed` — usually before
213
- the page's `turbo_stream_from` subscription has confirmed — so the first
214
- broadcast would be lost. Fix the ordering on the client with the packaged
215
- `streamConnected` helper: wait for Turbo to stamp the `connected`
216
- attribute on the stream source, then subscribe the graph channel.
217
-
218
- ```js
219
- // in the Stimulus controller driving the channel
220
- import { streamConnected } from "hibiki-rails"
221
-
222
- async connect() {
223
- this.consumer = createConsumer()
224
- await streamConnected(this.element.querySelector("turbo-cable-stream-source"))
225
- this.subscription = this.consumer.subscriptions.create(
226
- { channel: "CounterChannel", cid: this.cidValue }, {}
227
- )
228
- }
66
+ ```erb
67
+ <%= render "static_pages/counter" %>
229
68
  ```
230
69
 
231
- With that in place the server-rendered initial HTML is only a
232
- paint-avoidance placeholder — the first broadcast always lands and
233
- replaces it, so it doesn't have to match the graph's initial state.
70
+ Congratulations! Now you have your first reactive component!
234
71
 
235
- ## Error handling layers
72
+ ## Documentation
236
73
 
237
- 1. `rescue_from` on the channel — handles action errors, on the graph
238
- thread.
239
- 2. `Hibiki.error_handler = ->(error, effect) { ... }` — app-level routing
240
- for effect errors raised during a flush (the gem does not set this).
241
- 3. The graph worker's per-job rescue — everything unhandled lands in
242
- `Rails.error.report(..., source: "hibiki_rails")`. Override per channel
243
- via `build_graph_actor` and `GraphActor.new(on_error:)`.
74
+ Documentation site: <https://planetaska.github.io/hibiki/rails-introduction/>
244
75
 
245
76
  ## Development
246
77
 
247
78
  ```
248
- bundle exec rake # specs + rubocop (same as CI)
79
+ bundle exec rake # Ruby specs + rubocop
80
+ bun install && bun run test # the client's own specs
249
81
  ```
250
82
 
251
- The spec suite boots a minimal inline Rails app (`spec/support/dummy_app.rb`);
252
- the live end-to-end proof app is `spike/` in the parent repo.
83
+ Both are what CI runs. The Ruby suite boots a minimal inline Rails app (`spec/support/dummy_app.rb`); the JS suite (`spec/js/`) drives the real Stimulus controller in happy-dom against a stubbed Action Cable consumer.
84
+
85
+ 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
+
@@ -26,7 +26,9 @@
26
26
  //
27
27
  // Both shapes speak both transports. Transmit: the server's render
28
28
  // effects `transmit({ html: })` fragments that are swapped in by their
29
- // root DOM id; `received` is registered at subscribe time — before the
29
+ // root DOM id, and `transmit_value` messages that update every
30
+ // data-hibiki-value placeholder; `received` is registered at subscribe
31
+ // time — before the
30
32
  // server runs build_graph — so the effects' first transmits always land
31
33
  // (the server-rendered initial HTML is only a paint-avoidance
32
34
  // placeholder). Turbo broadcasts: when the controller's element contains
@@ -42,8 +44,22 @@
42
44
  // island root data-controller="hibiki"
43
45
  // data-hibiki-channel-value="CounterChannel"
44
46
  // data-hibiki-cid-value="<per-page-load id>"
45
- // controls data-hibiki-on="<event>-><action>" e.g. "click->increment"
47
+ // data-hibiki-params-value='{"record_id":7}' extra subscribe
48
+ // params, merged UNDER channel/cid so they can't override them
49
+ // controls data-hibiki-on="<event>-><action> ..." whitespace-separated;
50
+ // e.g. "click->load_more visible->load_more"
46
51
  // data-hibiki-with='{"index":3}' optional JSON payload
52
+ // data-hibiki-debounce="250" ms to let the gesture settle
53
+ // data-hibiki-confirm="Are you sure?" window.confirm gate
54
+ // data-hibiki-reset="false" keep a submitted form's inputs
55
+ // value sites data-hibiki-value="<name>" reactive-value placeholder;
56
+ // the server's transmit_value message updates every match
57
+ //
58
+ // The left side of `->` is a hibiki event name, of which DOM events are a
59
+ // subset: click, change, input, submit are delegated listeners, and
60
+ // `visible` is a pseudo-event backed by an IntersectionObserver (the
61
+ // element entering the viewport). Everything that is not "which event"
62
+ // is a sibling attribute, so the token grammar never has to grow.
47
63
  //
48
64
  // Register the generic controller under the identifier "hibiki" (the
49
65
  // helpers hardcode it):
@@ -61,6 +77,19 @@ let consumer
61
77
  // camelCase Stimulus method name → snake_case Ruby channel action.
62
78
  const underscore = (name) => name.replace(/([A-Z])/g, "_$1").toLowerCase()
63
79
 
80
+ // What a changed control contributes to its action's payload. A checkbox's
81
+ // `value` is its value ATTRIBUTE, not its state, so reading `value` made
82
+ // checking and unchecking send byte-identical payloads; a multi-select's
83
+ // `value` is only its first selected option. A radio needs no special case:
84
+ // `change` fires on the newly-checked input, so `value` is already right.
85
+ const controlValue = (control) => {
86
+ if (control.type === "checkbox") return control.checked
87
+ if (control.multiple && control.selectedOptions) {
88
+ return [...control.selectedOptions].map((option) => option.value)
89
+ }
90
+ return control.value
91
+ }
92
+
64
93
  // The subclassable base: one channel subscription per controller element,
65
94
  // identified by a per-page-load cid (data-<identifier>-cid-value).
66
95
  export class ChannelController extends Controller {
@@ -78,26 +107,50 @@ export class ChannelController extends Controller {
78
107
  if (source) await streamConnected(source)
79
108
  if (this.aborted) return // disconnected during the await
80
109
  this.subscription = consumer.subscriptions.create(
81
- { channel: this.channelName(), cid: this.cidValue },
110
+ this.subscribeParams(),
82
111
  { received: (data) => this.received(data) }
83
112
  )
84
113
  }
85
114
 
115
+ // What identifies this subscription to the server. Override to add
116
+ // params; keep channel/cid, which the Ruby side requires.
117
+ subscribeParams() {
118
+ return { channel: this.channelName(), cid: this.cidValue }
119
+ }
120
+
86
121
  disconnect() {
87
122
  this.aborted = true
88
123
  this.subscription?.unsubscribe()
89
124
  this.subscription = undefined
90
125
  }
91
126
 
92
- // DOM → server: what declared action methods call.
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.
93
130
  perform(action, payload = {}) {
94
- this.subscription.perform(action, payload)
131
+ this.subscription?.perform(action, payload)
95
132
  }
96
133
 
97
- // Server → DOM (transmit transport): swap each transmitted fragment in
98
- // by its root id. Broadcast-transport channels never transmit, and a
99
- // non-html transmit is not ours to interpret. Subclasses may override.
100
- received({ html }) {
134
+ // Server → DOM (transmit transport). Two message shapes:
135
+ //
136
+ // { value: { name, text } } — a reactive value (transmit_value): write
137
+ // the text into every [data-hibiki-value=name] placeholder, document-
138
+ // wide (a value may render outside its island; names are page-unique).
139
+ // textContent assignment keeps values text-only and preserves each
140
+ // site's own tag/classes, so per-placeholder styling survives updates.
141
+ //
142
+ // { html } — a fragment: swap it in by its root id.
143
+ //
144
+ // Anything else is not ours to interpret. Subclasses may override, but
145
+ // should call super (or handle `value`) to keep reactive values live.
146
+ received({ html, value }) {
147
+ if (value) {
148
+ const selector = `[data-hibiki-value="${CSS.escape(value.name)}"]`
149
+ for (const site of document.querySelectorAll(selector)) {
150
+ site.textContent = value.text
151
+ }
152
+ return
153
+ }
101
154
  if (!html) return
102
155
  const template = document.createElement("template")
103
156
  template.innerHTML = html
@@ -157,57 +210,174 @@ export class ChannelController extends Controller {
157
210
  // The generic controller: adds the data-hibiki-* wire protocol on top of
158
211
  // the base's plumbing.
159
212
  export default class HibikiController extends ChannelController {
160
- static values = { channel: String } // cid inherited from the base
213
+ // cid inherited from the base. `params` defaults to {} when the island
214
+ // stamps no data-hibiki-params-value.
215
+ static values = { channel: String, params: Object }
161
216
 
162
217
  // The island stamps its channel; no inference.
163
218
  channelName() {
164
219
  return this.channelValue
165
220
  }
166
221
 
222
+ // Extra subscribe params go UNDER channel/cid: they are client-supplied,
223
+ // so a page must not be able to point its subscription at another channel
224
+ // or steal another tab's graph by naming its cid. The server-side rule
225
+ // that goes with this is in Helpers#hibiki_island.
226
+ subscribeParams() {
227
+ return { ...this.paramsValue, ...super.subscribeParams() }
228
+ }
229
+
167
230
  async connect() {
168
231
  // Root-scoped delegation (bound to the island, not document): controls
169
232
  // inside server-replaced fragments keep working with no rebinding.
170
233
  // Set up synchronously so disconnect can always tear them down.
171
- this.listeners = ["click", "change", "submit"].map((type) => {
234
+ this.listeners = ["click", "change", "input", "submit"].map((type) => {
172
235
  const handler = (event) => this.forward(event)
173
236
  this.element.addEventListener(type, handler)
174
237
  return [type, handler]
175
238
  })
239
+
240
+ // Debounce bookkeeping: a WeakMap keyed by control (so detached
241
+ // elements don't pin memory) plus a flat set of live timeouts, which is
242
+ // what disconnect can actually iterate.
243
+ this.timers = new WeakMap()
244
+ this.pending = new Set()
245
+
246
+ // `visible` is not a DOM event, so it needs its own observer beside the
247
+ // delegated listeners. Always on, never a pluggable module: an
248
+ // IntersectionObserver watching zero elements costs nothing at runtime,
249
+ // while an optional import brings back the failure mode where the
250
+ // attribute is present, the code isn't, and nothing errors.
251
+ this.observer = new IntersectionObserver((entries) => {
252
+ for (const entry of entries) {
253
+ if (!entry.isIntersecting) continue
254
+ // Fire once per observation. The re-scan after the next swap
255
+ // observes the REPLACEMENT element, and its fresh initial callback
256
+ // is what stops the classic "the new page didn't fill the viewport,
257
+ // so the loop stalls" trap.
258
+ this.observer.unobserve(entry.target)
259
+ this.dispatch(entry.target, { type: "visible", target: entry.target })
260
+ }
261
+ })
262
+
263
+ // Re-scan at the two points a fragment can be swapped under us, rather
264
+ // than blanket-observing the document: a MutationObserver over the page
265
+ // is a real per-mutation cost paid by every app on it.
266
+ this.streamRender = (event) => {
267
+ const render = event.detail.render
268
+ event.detail.render = async (streamElement) => {
269
+ await render(streamElement)
270
+ this.scanSentinels()
271
+ }
272
+ }
273
+ document.addEventListener("turbo:before-stream-render", this.streamRender)
274
+
176
275
  await super.connect()
276
+ if (this.aborted) return
277
+ this.scanSentinels()
177
278
  }
178
279
 
179
280
  disconnect() {
180
281
  for (const [type, handler] of this.listeners) {
181
282
  this.element.removeEventListener(type, handler)
182
283
  }
284
+ document.removeEventListener("turbo:before-stream-render", this.streamRender)
285
+ for (const id of this.pending) clearTimeout(id)
286
+ this.pending.clear()
287
+ this.observer.disconnect()
183
288
  super.disconnect()
184
289
  }
185
290
 
186
- // DOM → server: forward a control's event as a channel action. Nested
187
- // islands: events bubble to every ancestor island's listener, so each
188
- // controller only acts when the control belongs to ITS island.
291
+ // The other swap point: hibiki's own transmit transport.
292
+ received(data) {
293
+ super.received(data)
294
+ if (data.html) this.scanSentinels()
295
+ }
296
+
297
+ // Observe every `visible->` sentinel this island owns. observe() is a
298
+ // no-op for an element already being observed, so re-scanning is cheap
299
+ // and cannot double-fire a sentinel that merely stayed put.
300
+ scanSentinels() {
301
+ for (const control of this.element.querySelectorAll('[data-hibiki-on*="visible->"]')) {
302
+ if (control.closest('[data-controller~="hibiki"]') === this.element) {
303
+ this.observer.observe(control)
304
+ }
305
+ }
306
+ }
307
+
308
+ // DOM → server: forward a control's event as a channel action.
189
309
  forward(event) {
190
310
  const control = event.target.closest("[data-hibiki-on]")
191
- if (!control) return
311
+ if (control) this.dispatch(control, event)
312
+ }
313
+
314
+ // The shared path for both sources of events — the delegated DOM
315
+ // listeners and the visibility observer.
316
+ dispatch(control, event) {
317
+ // Nested islands: events bubble to every ancestor island's listener, so
318
+ // each controller only acts when the control belongs to ITS island.
192
319
  if (control.closest('[data-controller~="hibiki"]') !== this.element) return
193
320
 
194
321
  const token = control.dataset.hibikiOn
195
322
  .split(/\s+/)
196
323
  .find((t) => t.startsWith(`${event.type}->`))
197
324
  if (!token) return
198
-
199
325
  const action = token.slice(event.type.length + 2)
326
+
327
+ // Before the confirm, not after: declining must not let the form
328
+ // navigate away.
329
+ if (event.type === "submit") event.preventDefault()
330
+
331
+ const message = control.dataset.hibikiConfirm
332
+ if (message && !window.confirm(message)) return
333
+
334
+ // The payload is built when the action actually fires, so a debounced
335
+ // input sends what the user finished typing rather than the first
336
+ // keystroke that started the timer.
337
+ const wait = Number(control.dataset.hibikiDebounce)
338
+ const fire = () => this.send(control, event, action)
339
+ if (wait > 0) this.debounce(control, action, wait, fire)
340
+ else fire()
341
+ }
342
+
343
+ send(control, event, action) {
200
344
  const payload = control.dataset.hibikiWith
201
345
  ? JSON.parse(control.dataset.hibikiWith)
202
346
  : {}
203
347
  if (event.type === "submit") {
204
- event.preventDefault()
205
348
  Object.assign(payload, Object.fromEntries(new FormData(control)))
206
- } else if (event.type === "change" && control.name) {
207
- payload[control.name] = control.value
349
+ } else if (control.name && (event.type === "change" || event.type === "input")) {
350
+ payload[control.name] = controlValue(control)
208
351
  }
209
352
  this.perform(action, payload)
210
- if (event.type === "submit") control.reset()
353
+ // Resetting is right for an "add" form and wrong for an edit one: it
354
+ // runs synchronously, before the server has replied, so a failed commit
355
+ // would discard what the user typed.
356
+ if (event.type === "submit" && control.dataset.hibikiReset !== "false") {
357
+ control.reset()
358
+ }
359
+ }
360
+
361
+ // One timer per (control, action): two events on one element debounce
362
+ // independently, and a second control's typing never cancels the first's.
363
+ debounce(control, action, wait, fire) {
364
+ let byAction = this.timers.get(control)
365
+ if (!byAction) {
366
+ byAction = new Map()
367
+ this.timers.set(control, byAction)
368
+ }
369
+ const previous = byAction.get(action)
370
+ if (previous) {
371
+ clearTimeout(previous)
372
+ this.pending.delete(previous)
373
+ }
374
+ const id = setTimeout(() => {
375
+ byAction.delete(action)
376
+ this.pending.delete(id)
377
+ fire()
378
+ }, wait)
379
+ byAction.set(action, id)
380
+ this.pending.add(id)
211
381
  }
212
382
  }
213
383
 
@@ -49,7 +49,7 @@ module Hibiki
49
49
 
50
50
  Render it from any page:
51
51
 
52
- <%= render #{class_name}Island.new %>
52
+ <%= render Components::#{class_name}Island.new %>
53
53
 
54
54
  MSG
55
55
  register_hint
@@ -8,7 +8,7 @@ class <%= class_name %>Channel < ApplicationCable::Channel
8
8
  include Hibiki::Rails::Channel
9
9
 
10
10
  def build_graph
11
- @component = <%= class_name %>.new
11
+ @component = Components::<%= class_name %>.new
12
12
 
13
13
  # First run is the dependency-collecting initial render.
14
14
  Hibiki::Phlex.render_effect(@component) { |html| transmit({ html: }) }
@@ -5,7 +5,7 @@
5
5
  # read as ordinary method calls (no .value anywhere), Rerenderable lets
6
6
  # the channel's render effect re-render this same instance, and Helpers
7
7
  # stamps the client's wire protocol.
8
- class <%= class_name %> < Phlex::HTML
8
+ class Components::<%= class_name %> < Phlex::HTML
9
9
  include Hibiki::Reactive
10
10
  include Hibiki::Phlex::Rerenderable
11
11
  include Hibiki::Rails::Helpers
@@ -3,15 +3,15 @@
3
3
  # The island wrapper: one channel subscription per render, identified by
4
4
  # a per-page-load cid. Render from any page:
5
5
  #
6
- # <%%= render <%= class_name %>Island.new %>
7
- class <%= class_name %>Island < Phlex::HTML
6
+ # <%%= render Components::<%= class_name %>Island.new %>
7
+ class Components::<%= class_name %>Island < Phlex::HTML
8
8
  include Hibiki::Rails::Helpers
9
9
 
10
10
  def view_template
11
11
  div(**hibiki_island(<%= class_name %>Channel, cid: SecureRandom.uuid)) do
12
12
  # A throwaway instance as a paint-avoidance placeholder; the
13
13
  # channel's long-lived instance takes over from its first transmit.
14
- render <%= class_name %>.new
14
+ render Components::<%= class_name %>.new
15
15
  end
16
16
  end
17
17
  end
@@ -36,13 +36,23 @@ module Hibiki
36
36
  end
37
37
 
38
38
  module ClassMethods
39
- private
39
+ # Lifecycle hooks, never client-invocable actions. Performing
40
+ # "build_graph" would rebuild the graph and leak the old root;
41
+ # "subscribed" would build a SECOND graph actor on the connection.
42
+ #
43
+ # These need subtracting because ActionCable computes action_methods
44
+ # as "public methods this class adds": #subscribed and #unsubscribed
45
+ # are private on its base class, so they are never subtracted for
46
+ # free, and a public app-side override — the shape the ActiveRecord
47
+ # guide's after_commit bridge invites — is added straight back by
48
+ # public_instance_methods(false).
49
+ HIDDEN_ACTIONS = %w[build_graph subscribed unsubscribed].freeze
40
50
 
41
- # #build_graph is a lifecycle hook, not a client-invocable action:
42
- # keep it out of action_methods even when an app defines it public
43
- # (a client performing "build_graph" would rebuild the graph and
44
- # leak the old root).
45
- def internal_methods = super + [:build_graph]
51
+ # Subtracted here rather than through ActionCable's #internal_methods
52
+ # hook, which only exists on 8.x: on Rails 7.1 and 7.2 that hook is
53
+ # never consulted, so an override of it is silently dead code. This
54
+ # works on every supported version.
55
+ def action_methods = super - HIDDEN_ACTIONS
46
56
  end
47
57
 
48
58
  # ActionCable's single dispatch point for incoming actions. The whole
@@ -92,6 +102,39 @@ module Hibiki
92
102
  raise NotImplementedError, "#{self.class} must implement #build_graph"
93
103
  end
94
104
 
105
+ # The channel half of a single reactive value (see Helpers#reactive for
106
+ # the placeholder half): wraps the block in an effect that transmits
107
+ # `{ value: { name:, text: } }` — the packaged client writes the text
108
+ # into every `[data-hibiki-value=NAME]` placeholder on the page (part
109
+ # of the data-hibiki wire contract, versioned with the client). Call
110
+ # from #build_graph; the block tracks whatever signals it reads (state,
111
+ # derived, or an expression over several). Values are text, never
112
+ # markup: the client assigns textContent, so nothing is interpreted as
113
+ # HTML. Returns the effect, owned by the graph root like any other.
114
+ #
115
+ # Equality-gated: an effect re-runs whenever ANY signal it read
116
+ # changed, so a bumped db_version re-sent every value's text even when
117
+ # the text was byte-identical — once per reactive value, per ping.
118
+ # The block is still called unconditionally, so dependency collection
119
+ # is untouched; only the transmit is skipped.
120
+ #
121
+ # NOTE for placeholders rendered INSIDE a broadcast-replaced fragment:
122
+ # render them with their CURRENT value, not a static default. The swap
123
+ # resets the DOM text, and this gate now suppresses the re-send that
124
+ # used to heal it. Placeholders outside the replaced fragment (a nav
125
+ # badge, controls that are never re-rendered) are unaffected.
126
+ def transmit_value(name, &compute)
127
+ name = Helpers.value_name(name)
128
+ last = Object.new # never == a String, so the first run always sends
129
+ Hibiki::Effect.new do
130
+ text = compute.call.to_s
131
+ unless text == last
132
+ last = text
133
+ transmit({ value: { name:, text: } })
134
+ end
135
+ end
136
+ end
137
+
95
138
  # Per-page-load graph identity, supplied by the page's subscription
96
139
  # (each tab is its own graph). Override to derive identity elsewhere.
97
140
  def cid = params[:cid]
@@ -46,9 +46,23 @@ module Hibiki
46
46
 
47
47
  # Per-job rescue keeps the worker alive: one bad action must not take
48
48
  # the whole graph down. StandardError only — an Interrupt should.
49
+ #
50
+ # Each job is one unit of work in the Rails sense, so it runs inside
51
+ # the executor: CurrentAttributes reset between jobs (state must not
52
+ # leak from one action to the next on a long-lived graph thread),
53
+ # Zeitwerk's permit_concurrent_loads around autoloads off the main
54
+ # thread, an AR query cache per job, and reloader participation.
55
+ #
56
+ # The rescue sits INSIDE the wrap deliberately. ExecutionWrapper.wrap
57
+ # reports anything that escapes it to Rails.error itself — handled:
58
+ # false, source "application.active_support" — and then re-raises, so
59
+ # rescuing outside would report every graph error twice, once as
60
+ # unhandled. Rescuing inside means @on_error stays the single sink for
61
+ # a StandardError; a non-StandardError still escapes the wrap and takes
62
+ # the worker down, exactly as before.
49
63
  def work
50
64
  while (job = @queue.pop)
51
- begin
65
+ ::Rails.application.executor.wrap do
52
66
  job.call
53
67
  rescue StandardError => e
54
68
  @on_error.call(e)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "cgi/escape"
3
4
  require "json"
4
5
 
5
6
  module Hibiki
@@ -14,7 +15,10 @@ module Hibiki
14
15
  # button(**on(:increment)) { "+" }
15
16
  # button(**on(:toggle, with: { index: 3 })) { "toggle" }
16
17
  # input(name: "step", **on(:set_step, event: :change))
17
- # form(**on(:add, event: :submit)) { ... }
18
+ # input(name: "q", **on(:search, event: :input))
19
+ # button(**on(:destroy, confirm: "Are you sure?")) { "delete" }
20
+ # button(**on(:load_more, event: %i[click visible])) { "more" }
21
+ # form(**on(:save, event: :submit, reset: false)) { ... }
18
22
  # end
19
23
  #
20
24
  # Both helpers return a `{ data: { ... } }` hash: splat it into Phlex
@@ -25,25 +29,156 @@ module Hibiki
25
29
  # The emitted attribute names are a private contract between these
26
30
  # helpers and the gem's JS — they version together; don't hand-write
27
31
  # them in app code.
32
+ #
33
+ # #reactive is the exception to the splat shape: it returns a complete
34
+ # placeholder element (`<%= reactive :doubled, 0 %>` in ERB). Phlex
35
+ # components splat instead: `span(**reactive_attrs(:doubled))`.
28
36
  module Helpers
37
+ # A reactive value's name lands in an attribute selector on both
38
+ # halves, so keep it to safe characters; the placeholder tag name
39
+ # lands in raw markup, so allowlist it too.
40
+ VALUE_NAME = /\A[a-z][a-z0-9_-]*\z/i
41
+ VALUE_TAG = /\A[a-z][a-z0-9-]*\z/i
42
+
43
+ # Event and action names share the hibiki_on attribute, which is a
44
+ # whitespace-separated token list — a name carrying a space or an
45
+ # arrow would silently change what the client dispatches. The dot is
46
+ # allowed so a future event qualifier (keydown.enter) needs no second
47
+ # grammar change.
48
+ EVENT_NAME = /\A[a-z][a-z0-9_.-]*\z/i
49
+
50
+ # Per-keystroke round trips are the failure mode #on exists to avoid,
51
+ # so :input carries a debounce unless the caller says otherwise. It is
52
+ # applied here rather than in the client, so the number is visible in
53
+ # the emitted markup instead of being an invisible default.
54
+ DEFAULT_INPUT_DEBOUNCE = 250
55
+
56
+ private_constant :VALUE_NAME, :VALUE_TAG, :EVENT_NAME
57
+
58
+ # The shared name validator for both halves of a reactive value (the
59
+ # view-side data-hibiki-value placeholder and the channel's
60
+ # #transmit_value message).
61
+ def self.value_name(name)
62
+ name = name.to_s
63
+ unless VALUE_NAME.match?(name)
64
+ raise ArgumentError,
65
+ "reactive value name #{name.inspect} must match #{VALUE_NAME.inspect}"
66
+ end
67
+ name
68
+ end
69
+
70
+ # The shared validator for both halves of an `event->action` token.
71
+ def self.event_name(name)
72
+ name = name.to_s
73
+ unless EVENT_NAME.match?(name)
74
+ raise ArgumentError,
75
+ "event or action name #{name.inspect} must match #{EVENT_NAME.inspect}"
76
+ end
77
+ name
78
+ end
79
+
29
80
  # The island root: one channel subscription per island, identified by
30
81
  # a per-page-load cid (each tab is its own graph). `channel` is the
31
82
  # channel class or its name as a string.
32
- def hibiki_island(channel, cid:)
83
+ #
84
+ # `params:` is a hash of extra subscribe params, reaching the channel
85
+ # as `params[:key]` beside `cid`. It is how a channel learns WHICH
86
+ # record its page is about (a show page's `record_id`), which is
87
+ # otherwise unexpressible — the subscription is the only server-side
88
+ # hook that runs before the graph is built.
89
+ #
90
+ # THE TRUST RULE. Subscribe params are client-supplied and untrusted,
91
+ # exactly like query params on a request: anyone can open a socket and
92
+ # send whatever they like. A channel may use one only to LOOK UP A
93
+ # RECORD INSIDE A SCOPE IT CHOOSES ITSELF —
94
+ # `current_user.books.find(params[:record_id])` — and must `reject`
95
+ # when the lookup fails. It must never interpolate a param into a
96
+ # streamable name, a class name, a column name, or a scope. The
97
+ # streamable a channel streams from is always derived server-side from
98
+ # the record it has already loaded and authorized. The client cannot
99
+ # override `channel` or `cid` through this hash.
100
+ def hibiki_island(channel, cid:, params: nil)
33
101
  channel_name = channel.is_a?(Class) ? channel.name : channel.to_s
34
- { data: { controller: "hibiki", hibiki_channel_value: channel_name,
35
- hibiki_cid_value: cid } }
102
+ data = { controller: "hibiki", hibiki_channel_value: channel_name,
103
+ hibiki_cid_value: cid }
104
+ data[:hibiki_params_value] = JSON.generate(params) unless params.nil?
105
+ { data: }
36
106
  end
37
107
 
38
- # Forward a DOM event on this element as a channel action. `event:`
39
- # picks the DOM event (:click, :change, :submit); `with:` is a hash
40
- # sent as the action's payload. The client adds event-derived data on
41
- # top: a changed control contributes `{ name => value }`, a submitted
42
- # form contributes its FormData (and is reset after the perform).
43
- def on(action, event: :click, with: nil)
44
- data = { hibiki_on: "#{event}->#{action}" }
45
- data[:hibiki_with] = JSON.generate(with) unless with.nil?
46
- { data: }
108
+ # Forward an event on this element as a channel action.
109
+ #
110
+ # `event:` names the event, or a list of them — the left side of the
111
+ # `->` is a hibiki event name, of which DOM events are a subset:
112
+ # :click, :change, :input, :submit, plus the :visible pseudo-event
113
+ # (an IntersectionObserver sentinel — the element entering the
114
+ # viewport). A list makes one element answer several, which is how a
115
+ # load-more button doubles as an infinite-scroll sentinel:
116
+ #
117
+ # on(:load_more, event: %i[click visible], with: { shown: rows.size })
118
+ #
119
+ # `with:` is a hash sent as the action's payload. The client adds
120
+ # event-derived data on top: a changed control contributes
121
+ # `{ name => value }` — a checkbox its checked state, a multi-select
122
+ # its selected values — and a submitted form contributes its FormData.
123
+ #
124
+ # Everything else is a per-control modifier, kept out of the token
125
+ # grammar so the `->` left side stays purely "which event":
126
+ #
127
+ # debounce: ms wait for the gesture to settle before performing.
128
+ # :input defaults to DEFAULT_INPUT_DEBOUNCE; pass 0 to
129
+ # send every keystroke.
130
+ # confirm: msg window.confirm before performing; declining performs
131
+ # nothing (and does not submit the form).
132
+ # reset: false keep a submitted form's inputs. The default resets
133
+ # them, which is right for an "add" form and wrong for
134
+ # an edit one — a failed commit would otherwise discard
135
+ # what the user typed, synchronously, before the server
136
+ # has even replied.
137
+ def on(action, event: :click, with: nil, debounce: nil, confirm: nil, reset: nil)
138
+ action = Helpers.event_name(action)
139
+ events = Array(event).map { Helpers.event_name(it) }
140
+ debounce = DEFAULT_INPUT_DEBOUNCE if debounce.nil? && events.include?("input")
141
+ tokens = events.map { "#{it}->#{action}" }.join(" ")
142
+ { data: { hibiki_on: tokens }
143
+ .merge(on_modifiers(with:, debounce:, confirm:, reset:)) }
144
+ end
145
+
146
+ # Placeholder for a single reactive value: `<%= reactive :doubled, 0 %>`
147
+ # paints `<span data-hibiki-value="doubled">0</span>`; the channel's
148
+ # `transmit_value(:doubled) { ... }` keeps it fresh. The same value may
149
+ # be placed any number of times, anywhere on the page — every
150
+ # placeholder updates (the client matches document-wide, so a value can
151
+ # render outside its island too). Names must be page-unique across
152
+ # channels. Only the placeholder text is server-rendered: each site
153
+ # keeps its own tag, classes, and attributes across updates.
154
+ def reactive(name, placeholder = "", tag_name: :span)
155
+ tag_name = tag_name.to_s
156
+ unless VALUE_TAG.match?(tag_name)
157
+ raise ArgumentError,
158
+ "reactive value tag #{tag_name.inspect} must match #{VALUE_TAG.inspect}"
159
+ end
160
+ html = %(<#{tag_name} data-hibiki-value="#{Helpers.value_name(name)}">) +
161
+ %(#{CGI.escapeHTML(placeholder.to_s)}</#{tag_name}>)
162
+ html.respond_to?(:html_safe) ? html.html_safe : html
163
+ end
164
+
165
+ # The value's attributes, for stamping the placeholder yourself — the
166
+ # Phlex form of #reactive: `span(**reactive_attrs(:doubled)) { "0" }`.
167
+ def reactive_attrs(name) = { data: { hibiki_value: Helpers.value_name(name) } }
168
+
169
+ private
170
+
171
+ # #on's per-control modifiers, kept out of the token grammar. Each is
172
+ # omitted when it matches the client's own default, so the common call
173
+ # still stamps exactly one attribute.
174
+ def on_modifiers(with:, debounce:, confirm:, reset:)
175
+ debounce = Integer(debounce) if debounce
176
+ {
177
+ hibiki_with: (JSON.generate(with) unless with.nil?),
178
+ hibiki_debounce: (debounce unless debounce.nil? || debounce.zero?),
179
+ hibiki_confirm: confirm&.to_s,
180
+ hibiki_reset: ("false" if reset == false)
181
+ }.compact
47
182
  end
48
183
  end
49
184
  end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hibiki
4
+ module Rails
5
+ # A reactive form object over one ActiveRecord record: hydrate its
6
+ # attributes into signals at one edge, work reactively in the middle,
7
+ # commit back at the other. The record itself never enters the graph.
8
+ #
9
+ # class TodoForm
10
+ # include Hibiki::Rails::ReactiveForm
11
+ #
12
+ # reactive_attributes Todo, :title, :done
13
+ #
14
+ # derived(:title_error) { "can't be blank" if title.strip.empty? }
15
+ # derived(:valid?) { title_error.nil? }
16
+ # end
17
+ #
18
+ # form = TodoForm.from(Todo.find(id)) # or Todo.new — see below
19
+ # form.title = "buy milk" # a plain signal write
20
+ # form.dirty? # => true
21
+ # form.commit # => false if invalid
22
+ # form.error_for(:title) # => the model's own message
23
+ #
24
+ # One form class serves create AND update, the `form_with model:`
25
+ # convention: `from(Todo.new)` hydrates the column defaults and `commit`
26
+ # on an unpersisted record INSERTs, so create-vs-update is invisible to
27
+ # the caller. `dirty?` on a create form means "changed from the
28
+ # defaults" — exactly what enables a Create button.
29
+ #
30
+ # Two layers of validation, deliberately: hand-written deriveds give
31
+ # per-keystroke feedback (hand-picked, like client-side validation),
32
+ # while the model's own `validates` stay authoritative at commit and
33
+ # land in #errors. Nothing here names an ActiveRecord constant — the
34
+ # record is duck-typed (readers, #update, #save!, #errors,
35
+ # #persisted?) — but the casting below is AR's attribute API, which is
36
+ # why this lives in the Rails glue gem and not in the core.
37
+ module ReactiveForm
38
+ def self.included(base)
39
+ base.include(Hibiki::Reactive)
40
+ base.extend(ClassMethods)
41
+ # One derived over the whole attribute set rather than per-field
42
+ # change tracking: cheap, and enough for "enable the save button".
43
+ base.derived(:dirty?) { to_h != __hibiki_snapshot.value }
44
+ end
45
+
46
+ module ClassMethods
47
+ # Hydrate from a record — the only supported constructor. Extra
48
+ # arguments are forwarded to #initialize, so a form with its own
49
+ # constructor still works.
50
+ def from(record, ...) = new(...).hydrate(record)
51
+
52
+ # reactive_attributes Todo, :title, :done
53
+ #
54
+ # The model may be a Class or a String/Symbol; a name is resolved on
55
+ # every use, so a form class under app/ never pins a constant across
56
+ # a Zeitwerk reload.
57
+ def reactive_attributes(model, *names)
58
+ @hibiki_model = model
59
+ @hibiki_attributes = names.map(&:to_sym)
60
+ casts = __hibiki_casts
61
+ @hibiki_attributes.each do |name|
62
+ state name
63
+ casts.define_method(:"#{name}=") { |value| super(self.class.hibiki_type(name).cast(value)) }
64
+ end
65
+ end
66
+
67
+ def hibiki_attributes
68
+ @hibiki_attributes || __hibiki_inherited(:hibiki_attributes) || []
69
+ end
70
+
71
+ def hibiki_model
72
+ model = @hibiki_model || __hibiki_inherited(:hibiki_model)
73
+ raise "#{self} has no reactive_attributes declaration" if model.nil?
74
+
75
+ model.is_a?(Module) ? model : model.to_s.constantize
76
+ end
77
+
78
+ # Channel action params arrive as strings; casting through the
79
+ # model's own attribute type is the difference between
80
+ # `done = "false"` meaning false and meaning true.
81
+ def hibiki_type(name) = hibiki_model.type_for_attribute(name.to_s)
82
+
83
+ private
84
+
85
+ # Declarations live in class ivars, so subclasses have to walk up
86
+ # for them — the generated methods inherit on their own.
87
+ def __hibiki_inherited(reader)
88
+ superclass.public_send(reader) if superclass.respond_to?(reader)
89
+ end
90
+
91
+ # One module per class, prepended once: the casting writer wraps
92
+ # the writer `state` defined on the class itself (via super)
93
+ # instead of replacing it.
94
+ def __hibiki_casts = @__hibiki_casts ||= Module.new.tap { prepend(it) }
95
+ end
96
+
97
+ # The record, held in a plain ivar and NEVER in a signal: it is the
98
+ # boundary, touched only by #hydrate and #commit.
99
+ attr_reader :record
100
+
101
+ # Also the "reset from a reloaded record" path. One batch, so a
102
+ # re-hydrate is one effect run rather than one per attribute.
103
+ def hydrate(record)
104
+ @record = record
105
+ Hibiki.batch do
106
+ self.class.hibiki_attributes.each { |name| public_send(:"#{name}=", record.public_send(name)) }
107
+ __hibiki_snapshot.value = to_h
108
+ __hibiki_errors.value = {}
109
+ end
110
+ self
111
+ end
112
+
113
+ def persisted? = record&.persisted? || false
114
+
115
+ # Reads every attribute signal, so anything derived from it tracks
116
+ # them all.
117
+ def to_h = self.class.hibiki_attributes.to_h { |name| [name, public_send(name)] }
118
+
119
+ # Write the record. Returns false and mirrors the model's errors into
120
+ # #errors when validation fails (the Rails #save convention). On
121
+ # success the form re-hydrates: callbacks and database defaults may
122
+ # have moved values, and #persisted? flips after an INSERT.
123
+ # rubocop:disable Naming/PredicateMethod -- boolean without a ?, exactly like AR's #save
124
+ def commit
125
+ record = __hibiki_record!
126
+ if record.update(**to_h)
127
+ hydrate(record)
128
+ true
129
+ else
130
+ __hibiki_errors.value = record.errors.to_hash
131
+ false
132
+ end
133
+ end
134
+ # rubocop:enable Naming/PredicateMethod
135
+
136
+ # The raising half. #commit has already assigned the attributes and
137
+ # mirrored the errors, so #save! re-validates the same record and
138
+ # raises ActiveRecord::RecordInvalid — no rescue, and no AR constant
139
+ # named here.
140
+ def commit! = commit || __hibiki_record!.save!
141
+
142
+ # { title: ["can't be blank"] } — mirrored at a failed commit,
143
+ # cleared at a successful one. Reactive like any other signal read:
144
+ # an effect over #error_for repaints when a commit fails.
145
+ def errors = __hibiki_errors.value
146
+
147
+ def error_for(name) = errors[name.to_sym]&.first
148
+
149
+ private
150
+
151
+ # Snapshot and errors are plain States rather than `state` macros on
152
+ # purpose: the macro would generate public writers and invite writes
153
+ # that bypass #hydrate and #commit.
154
+ def __hibiki_snapshot = @__hibiki_snapshot ||= Hibiki::State.new({})
155
+
156
+ def __hibiki_errors = @__hibiki_errors ||= Hibiki::State.new({})
157
+
158
+ def __hibiki_record!
159
+ record || raise("#{self.class} has no record — build it with .from(record)")
160
+ end
161
+ end
162
+ end
163
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hibiki
4
4
  module Rails
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/lib/hibiki/rails.rb CHANGED
@@ -12,8 +12,25 @@ module Hibiki
12
12
  # This is the outer layer of the funnel — a flush error first goes to
13
13
  # an app-set Hibiki.error_handler if there is one, and only re-raises
14
14
  # into the actor's per-job rescue when there isn't.
15
+ #
16
+ # It ALSO logs, in local environments only. ActiveSupport::ErrorReporter
17
+ # has no subscribers by default, so report alone means a graph-thread
18
+ # exception vanishes completely in a stock app: no log line, no stack,
19
+ # and the only symptom is a fragment that stops updating. That cost real
20
+ # debugging time while the CRUD reference app was built. `local?` is
21
+ # development + test, so production apps — which do have a subscriber —
22
+ # get no duplicate line. Swap the whole thing per graph via
23
+ # GraphActor.new(on_error:) if you want different behaviour.
15
24
  def self.default_error_reporter
16
- ->(error) { ::Rails.error.report(error, handled: true, source: "hibiki_rails") }
25
+ lambda do |error|
26
+ if ::Rails.env.local? && ::Rails.logger
27
+ ::Rails.logger.error(
28
+ "[hibiki_rails] #{error.class}: #{error.message}\n" \
29
+ "#{Array(error.backtrace).first(20).join("\n")}"
30
+ )
31
+ end
32
+ ::Rails.error.report(error, handled: true, source: "hibiki_rails")
33
+ end
17
34
  end
18
35
  end
19
36
  end
@@ -25,4 +42,5 @@ require_relative "rails/debounce"
25
42
  require_relative "rails/broadcasts"
26
43
  require_relative "rails/channel"
27
44
  require_relative "rails/helpers"
45
+ require_relative "rails/reactive_form"
28
46
  require_relative "rails/engine"
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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - planetaska
@@ -102,6 +102,7 @@ files:
102
102
  - lib/hibiki/rails/engine.rb
103
103
  - lib/hibiki/rails/graph_actor.rb
104
104
  - lib/hibiki/rails/helpers.rb
105
+ - lib/hibiki/rails/reactive_form.rb
105
106
  - lib/hibiki/rails/registry.rb
106
107
  - lib/hibiki/rails/version.rb
107
108
  - lib/hibiki_rails.rb