hibiki_rails 0.2.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: 62779990991fb78726bc665b7ad93a93dabee5b49892fa99f6486ad42dc41679
4
- data.tar.gz: 9cac4fa0143ab6b5f031d9ea0d9d213a1505108e1aa06894773d59f8594b6023
3
+ metadata.gz: 45614c5c98f4439de6f22da45a49d163ab7a06eccf53ed36108ee04d6b5c7f78
4
+ data.tar.gz: c1dd28764cc7462dbb7ceaa5d0b76e1a0371bd8cc5523fe54545533d7b759c36
5
5
  SHA512:
6
- metadata.gz: bf6340bdef27fd3c320fff3a8d7fb90967d883783a965e61a90ccbae5d536b7bdcdfcba536d827089d3b389af1c07439ff7031ee11792c95a579752c8b235706
7
- data.tar.gz: a9337451b21ecd955590683648f8a6d824bafa703ce096937387cff0fafaedf5ee5a959089558f7bf02037b00d39e40fd9fcab1bf3176cd976dcd1b957ab6443
6
+ metadata.gz: 4908f9d337c86d575287ff2d21d3d74cd112334e893e8b042cb07e7e8a1de141ddd892b17b4b86d90ac2008ce8c7d9f4390fd1ebdd1cc1cafe88e75c6530c360
7
+ data.tar.gz: 392976be271a02c7b5e354ec0d3e46117e1a8772cb0a68b99d4570f1f95309bc7abac3e0cd3ca38961eaea5eb849d4e4343effa55911629d367555949ac93408
data/README.md CHANGED
@@ -76,8 +76,11 @@ Documentation site: <https://planetaska.github.io/hibiki/rails-introduction/>
76
76
  ## Development
77
77
 
78
78
  ```
79
- 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
80
81
  ```
81
82
 
82
- The spec suite boots a minimal inline Rails app (`spec/support/dummy_app.rb`).
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/).
83
86
 
@@ -44,11 +44,23 @@
44
44
  // island root data-controller="hibiki"
45
45
  // data-hibiki-channel-value="CounterChannel"
46
46
  // data-hibiki-cid-value="<per-page-load id>"
47
- // 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"
48
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
49
55
  // value sites data-hibiki-value="<name>" reactive-value placeholder;
50
56
  // the server's transmit_value message updates every match
51
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.
63
+ //
52
64
  // Register the generic controller under the identifier "hibiki" (the
53
65
  // helpers hardcode it):
54
66
  //
@@ -65,6 +77,19 @@ let consumer
65
77
  // camelCase Stimulus method name → snake_case Ruby channel action.
66
78
  const underscore = (name) => name.replace(/([A-Z])/g, "_$1").toLowerCase()
67
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
+
68
93
  // The subclassable base: one channel subscription per controller element,
69
94
  // identified by a per-page-load cid (data-<identifier>-cid-value).
70
95
  export class ChannelController extends Controller {
@@ -82,20 +107,28 @@ export class ChannelController extends Controller {
82
107
  if (source) await streamConnected(source)
83
108
  if (this.aborted) return // disconnected during the await
84
109
  this.subscription = consumer.subscriptions.create(
85
- { channel: this.channelName(), cid: this.cidValue },
110
+ this.subscribeParams(),
86
111
  { received: (data) => this.received(data) }
87
112
  )
88
113
  }
89
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
+
90
121
  disconnect() {
91
122
  this.aborted = true
92
123
  this.subscription?.unsubscribe()
93
124
  this.subscription = undefined
94
125
  }
95
126
 
96
- // 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.
97
130
  perform(action, payload = {}) {
98
- this.subscription.perform(action, payload)
131
+ this.subscription?.perform(action, payload)
99
132
  }
100
133
 
101
134
  // Server → DOM (transmit transport). Two message shapes:
@@ -177,57 +210,174 @@ export class ChannelController extends Controller {
177
210
  // The generic controller: adds the data-hibiki-* wire protocol on top of
178
211
  // the base's plumbing.
179
212
  export default class HibikiController extends ChannelController {
180
- 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 }
181
216
 
182
217
  // The island stamps its channel; no inference.
183
218
  channelName() {
184
219
  return this.channelValue
185
220
  }
186
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
+
187
230
  async connect() {
188
231
  // Root-scoped delegation (bound to the island, not document): controls
189
232
  // inside server-replaced fragments keep working with no rebinding.
190
233
  // Set up synchronously so disconnect can always tear them down.
191
- this.listeners = ["click", "change", "submit"].map((type) => {
234
+ this.listeners = ["click", "change", "input", "submit"].map((type) => {
192
235
  const handler = (event) => this.forward(event)
193
236
  this.element.addEventListener(type, handler)
194
237
  return [type, handler]
195
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
+
196
275
  await super.connect()
276
+ if (this.aborted) return
277
+ this.scanSentinels()
197
278
  }
198
279
 
199
280
  disconnect() {
200
281
  for (const [type, handler] of this.listeners) {
201
282
  this.element.removeEventListener(type, handler)
202
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()
203
288
  super.disconnect()
204
289
  }
205
290
 
206
- // DOM server: forward a control's event as a channel action. Nested
207
- // islands: events bubble to every ancestor island's listener, so each
208
- // 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.
209
309
  forward(event) {
210
310
  const control = event.target.closest("[data-hibiki-on]")
211
- 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.
212
319
  if (control.closest('[data-controller~="hibiki"]') !== this.element) return
213
320
 
214
321
  const token = control.dataset.hibikiOn
215
322
  .split(/\s+/)
216
323
  .find((t) => t.startsWith(`${event.type}->`))
217
324
  if (!token) return
218
-
219
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) {
220
344
  const payload = control.dataset.hibikiWith
221
345
  ? JSON.parse(control.dataset.hibikiWith)
222
346
  : {}
223
347
  if (event.type === "submit") {
224
- event.preventDefault()
225
348
  Object.assign(payload, Object.fromEntries(new FormData(control)))
226
- } else if (event.type === "change" && control.name) {
227
- payload[control.name] = control.value
349
+ } else if (control.name && (event.type === "change" || event.type === "input")) {
350
+ payload[control.name] = controlValue(control)
228
351
  }
229
352
  this.perform(action, payload)
230
- 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)
231
381
  }
232
382
  }
233
383
 
@@ -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
@@ -101,10 +111,27 @@ module Hibiki
101
111
  # derived, or an expression over several). Values are text, never
102
112
  # markup: the client assigns textContent, so nothing is interpreted as
103
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.
104
126
  def transmit_value(name, &compute)
105
127
  name = Helpers.value_name(name)
128
+ last = Object.new # never == a String, so the first run always sends
106
129
  Hibiki::Effect.new do
107
- transmit({ value: { name:, text: compute.call.to_s } })
130
+ text = compute.call.to_s
131
+ unless text == last
132
+ last = text
133
+ transmit({ value: { name:, text: } })
134
+ end
108
135
  end
109
136
  end
110
137
 
@@ -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)
@@ -15,7 +15,10 @@ module Hibiki
15
15
  # button(**on(:increment)) { "+" }
16
16
  # button(**on(:toggle, with: { index: 3 })) { "toggle" }
17
17
  # input(name: "step", **on(:set_step, event: :change))
18
- # 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)) { ... }
19
22
  # end
20
23
  #
21
24
  # Both helpers return a `{ data: { ... } }` hash: splat it into Phlex
@@ -36,7 +39,21 @@ module Hibiki
36
39
  # lands in raw markup, so allowlist it too.
37
40
  VALUE_NAME = /\A[a-z][a-z0-9_-]*\z/i
38
41
  VALUE_TAG = /\A[a-z][a-z0-9-]*\z/i
39
- private_constant :VALUE_NAME, :VALUE_TAG
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
40
57
 
41
58
  # The shared name validator for both halves of a reactive value (the
42
59
  # view-side data-hibiki-value placeholder and the channel's
@@ -50,24 +67,80 @@ module Hibiki
50
67
  name
51
68
  end
52
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
+
53
80
  # The island root: one channel subscription per island, identified by
54
81
  # a per-page-load cid (each tab is its own graph). `channel` is the
55
82
  # channel class or its name as a string.
56
- 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)
57
101
  channel_name = channel.is_a?(Class) ? channel.name : channel.to_s
58
- { data: { controller: "hibiki", hibiki_channel_value: channel_name,
59
- 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: }
60
106
  end
61
107
 
62
- # Forward a DOM event on this element as a channel action. `event:`
63
- # picks the DOM event (:click, :change, :submit); `with:` is a hash
64
- # sent as the action's payload. The client adds event-derived data on
65
- # top: a changed control contributes `{ name => value }`, a submitted
66
- # form contributes its FormData (and is reset after the perform).
67
- def on(action, event: :click, with: nil)
68
- data = { hibiki_on: "#{event}->#{action}" }
69
- data[:hibiki_with] = JSON.generate(with) unless with.nil?
70
- { 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:)) }
71
144
  end
72
145
 
73
146
  # Placeholder for a single reactive value: `<%= reactive :doubled, 0 %>`
@@ -92,6 +165,21 @@ module Hibiki
92
165
  # The value's attributes, for stamping the placeholder yourself — the
93
166
  # Phlex form of #reactive: `span(**reactive_attrs(:doubled)) { "0" }`.
94
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
182
+ end
95
183
  end
96
184
  end
97
185
  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.2.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.2.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