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 +4 -4
- data/README.md +47 -213
- data/app/assets/javascripts/hibiki.js +190 -20
- data/lib/generators/hibiki/rails/phlex/phlex_generator.rb +1 -1
- data/lib/generators/hibiki/rails/phlex/templates/channel.rb.tt +1 -1
- data/lib/generators/hibiki/rails/phlex/templates/component.rb.tt +1 -1
- data/lib/generators/hibiki/rails/phlex/templates/island_component.rb.tt +3 -3
- data/lib/hibiki/rails/channel.rb +49 -6
- data/lib/hibiki/rails/graph_actor.rb +15 -1
- data/lib/hibiki/rails/helpers.rb +148 -13
- data/lib/hibiki/rails/reactive_form.rb +163 -0
- data/lib/hibiki/rails/version.rb +1 -1
- data/lib/hibiki/rails.rb +19 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 45614c5c98f4439de6f22da45a49d163ab7a06eccf53ed36108ee04d6b5c7f78
|
|
4
|
+
data.tar.gz: c1dd28764cc7462dbb7ceaa5d0b76e1a0371bd8cc5523fe54545533d7b759c36
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
53
|
-
matching stream:
|
|
27
|
+
Or `gem install hibiki hibiki_rails`.
|
|
54
28
|
|
|
55
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
72
|
+
## Documentation
|
|
236
73
|
|
|
237
|
-
|
|
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
|
|
79
|
+
bundle exec rake # Ruby specs + rubocop
|
|
80
|
+
bun install && bun run test # the client's own specs
|
|
249
81
|
```
|
|
250
82
|
|
|
251
|
-
The
|
|
252
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
131
|
+
this.subscription?.perform(action, payload)
|
|
95
132
|
}
|
|
96
133
|
|
|
97
|
-
// Server → DOM (transmit transport)
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
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 (
|
|
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"
|
|
207
|
-
payload[control.name] = control
|
|
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
|
-
|
|
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
|
|
|
@@ -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 =
|
|
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
|
|
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
|
|
7
|
-
class
|
|
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
|
|
14
|
+
render Components::<%= class_name %>.new
|
|
15
15
|
end
|
|
16
16
|
end
|
|
17
17
|
end
|
data/lib/hibiki/rails/channel.rb
CHANGED
|
@@ -36,13 +36,23 @@ module Hibiki
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
module ClassMethods
|
|
39
|
-
|
|
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
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
def
|
|
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
|
-
|
|
65
|
+
::Rails.application.executor.wrap do
|
|
52
66
|
job.call
|
|
53
67
|
rescue StandardError => e
|
|
54
68
|
@on_error.call(e)
|
data/lib/hibiki/rails/helpers.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
data/lib/hibiki/rails/version.rb
CHANGED
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
|
-
|
|
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.
|
|
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
|