phlex-reactive 0.2.9 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +220 -0
- data/README.md +161 -30
- data/app/controllers/phlex/reactive/actions_controller.rb +72 -11
- data/app/javascript/phlex/reactive/reactive_controller.js +150 -22
- data/lib/generators/phlex/reactive/install/install_generator.rb +6 -6
- data/lib/phlex/reactive/component.rb +117 -21
- data/lib/phlex/reactive/engine.rb +18 -9
- data/lib/phlex/reactive/reply.rb +102 -0
- data/lib/phlex/reactive/response.rb +146 -6
- data/lib/phlex/reactive/streamable.rb +158 -12
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +57 -5
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8811ee6a3c0f044fd7c9d16843a641296b33b8fcb5872f5bc8822257f837148e
|
|
4
|
+
data.tar.gz: 9398a1c088680e83b8bed3398342345ceb817703183df0985a1948e29df021cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b17a5d83f62e801d0f8673f824f929a82d1c7efc2d0aa2274034639ed94b2c8c4723f11484427f051c7be15d523a0b99cd11594bed0d701e4ff2e07604a1edd8
|
|
7
|
+
data.tar.gz: b5990726c0531bd2d6bc2da23df8bb2dbf5220ee702306c86f8094690da8c4a1752fa8ffb7949261050858256e5fd70854491603ac2306221c9f7bf2078f943c
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,191 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
10
|
|
|
11
|
+
- **File / multipart params in a reactive action (#34).** An action can now accept
|
|
12
|
+
an uploaded file: declare `params: { file: :file }` (or `[:file]` for multiple).
|
|
13
|
+
When the reactive root holds a populated `<input type="file">`, the client sends
|
|
14
|
+
the action as multipart `FormData` instead of JSON — `token` + `act` + scalar
|
|
15
|
+
params as fields, the file(s) appended — and the endpoint coerces `:file` to the
|
|
16
|
+
`ActionDispatch::Http::UploadedFile`, passed through untouched. A non-file value
|
|
17
|
+
sent to a `:file` param is dropped (the keyword default applies), consistent with
|
|
18
|
+
the #16 coercion rules — and for a `[:file]` array, a non-file *element* (a
|
|
19
|
+
forged/mixed payload) is rejected from the array too, so the internal coercion
|
|
20
|
+
sentinel never leaks to the action. A `<input type="file" multiple>` keeps its
|
|
21
|
+
array shape (`params[name][]`) even when the user picks exactly one file, so a
|
|
22
|
+
`[:file]` schema still coerces it. Token threading and the re-render/morph are
|
|
23
|
+
identical; only the request encoding changes when a file is present — so
|
|
24
|
+
attaching a document/receipt/image stays a reactive action instead of dropping
|
|
25
|
+
out to a bespoke controller + upload Stimulus controller. Covered end-to-end:
|
|
26
|
+
server coercion (request specs), the FormData wire shape (bun unit tests), and a
|
|
27
|
+
real browser upload under Puma + Falcon (system spec).
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- **Linter: Standard → RuboCop.** The gem now lints with RuboCop (all new cops
|
|
32
|
+
enabled, `NewCops: enable`) instead of StandardRB, so the suite teaches and
|
|
33
|
+
enforces newer Ruby idioms that Standard leaves alone — chiefly the Ruby 3.4
|
|
34
|
+
`it` implicit single block parameter (`map { it.foo }`) via
|
|
35
|
+
`Style/ItBlockParameter: always`. The whole tree was autocorrected; the
|
|
36
|
+
lib/app changes were the `|x| x.foo` → `it.foo` rename plus hash-brace
|
|
37
|
+
spacing — behavior is unchanged and all specs are green. The gemspec and
|
|
38
|
+
Rakefile are excluded from `Style/ItBlockParameter` (their `do |spec| … end`
|
|
39
|
+
config blocks read better named); a nested Phlex content block and a scope
|
|
40
|
+
lambda carry inline disables where blanket `it` would collapse two parameters
|
|
41
|
+
or break a `where(room:)` kwarg shorthand; and blocks that fed a
|
|
42
|
+
keyword-argument shorthand (`Component.new(todo:)`) were rewritten to an
|
|
43
|
+
explicit `Component.new(todo: it)` so the `it` rename can't silently change
|
|
44
|
+
the keyword. Component-
|
|
45
|
+
aware relaxations (line length, `Lint/MissingSuper`, unused action params)
|
|
46
|
+
are scoped to `spec/dummy/app/components/**`. `bundle exec standardrb` is now
|
|
47
|
+
`bundle exec rubocop`; `rake`'s default runs `spec + rubocop`.
|
|
48
|
+
- **Added `rubocop-capybara` and `rubocop-thread_safety`.** Capybara lints the
|
|
49
|
+
browser specs (clean today — the suite already uses waiting matchers).
|
|
50
|
+
thread_safety stays on as a tripwire for unsafe shared mutable state on the
|
|
51
|
+
request/broadcast path; its `ClassInstanceVariable` /
|
|
52
|
+
`ClassAndModuleAttributes` cops are scoped off only in the three files whose
|
|
53
|
+
flagged class-level state is deliberate and audited — module-level config
|
|
54
|
+
(`lib/phlex/reactive.rb`, set once at boot), class-definition-time DSL
|
|
55
|
+
registries (`component.rb`), and the per-thread view-context cache's integer
|
|
56
|
+
generation counter (`streamable.rb`). An adversarial audit confirmed none are
|
|
57
|
+
request-path hazards; the lazy `||=` config defaults are idempotent. (A
|
|
58
|
+
follow-up may warm `verifier`/`renderer` at boot to remove even the
|
|
59
|
+
benign-by-idempotency first-call race — out of scope for this lint change.)
|
|
60
|
+
|
|
61
|
+
### Removed
|
|
62
|
+
|
|
63
|
+
- **Dropped the `standard` development dependency** in favor of `rubocop`,
|
|
64
|
+
`rubocop-capybara`, `rubocop-performance`, `rubocop-rake`, `rubocop-rspec`,
|
|
65
|
+
and `rubocop-thread_safety`.
|
|
66
|
+
|
|
67
|
+
### BREAKING
|
|
68
|
+
|
|
69
|
+
- **Minimum Ruby is now 3.4** (was 3.2). Enabling the `it` block parameter
|
|
70
|
+
requires Ruby 3.4, so `required_ruby_version` is `>= 3.4.0` and the CI matrix
|
|
71
|
+
drops 3.2 and 3.3. Stay on phlex-reactive 0.3.x if you need Ruby 3.2/3.3.
|
|
72
|
+
|
|
73
|
+
### Fixed
|
|
74
|
+
|
|
75
|
+
- **Multipart path silently dropped an explicit nested-hash / array param (#39).**
|
|
76
|
+
When an action declared a `:file` param alongside an explicit nested (hash/array)
|
|
77
|
+
param, the nested param was dropped on the multipart path while the JSON path
|
|
78
|
+
handled it correctly — the two encodings were asymmetric. The client's
|
|
79
|
+
`#buildFormData` `JSON.stringify`'d a non-scalar param into one
|
|
80
|
+
`params[key]='<json>'` field, which the server received as an un-decodable
|
|
81
|
+
`String` leaf and dropped (nested hash → `{}`, array → key removed). Fixed
|
|
82
|
+
client-side: `#buildFormData` now bracket-expands a nested object/array into
|
|
83
|
+
`params[key][sub]` / `params[key][index][...]` fields (arrays use numeric
|
|
84
|
+
indices) — the same Rails-form shape the server's `expand_bracket_keys` /
|
|
85
|
+
`array_values` already parse, so a JSON body and a multipart body now coerce
|
|
86
|
+
identically. The server is unchanged. One intentional divergence: an empty
|
|
87
|
+
array/object as a whole param can't be carried by `FormData`, so the multipart
|
|
88
|
+
path omits the key (the action's keyword default applies) rather than sending an
|
|
89
|
+
explicit-clear `[]`/`{}`. Covered end-to-end: the bracketed multipart shape
|
|
90
|
+
coerces correctly (request specs), a `:file` alongside a nested param both
|
|
91
|
+
survive (request spec), and the FormData wire shape (bun unit tests).
|
|
92
|
+
|
|
93
|
+
- **`to_stream_token` emitted an EMPTY token, making non-self-rendering replies
|
|
94
|
+
add-once-only (cosmos#1939).** `Streamable#to_stream_token` guarded on
|
|
95
|
+
`respond_to?(:reactive_token)`, but `Component#reactive_token` is **private**, so
|
|
96
|
+
the guard was false for every component and the refresh stream carried
|
|
97
|
+
`data-reactive-token-value=""`. Any reply that opts out of the full-self replace
|
|
98
|
+
but relies on the token-only refresh — `reply.streams` (#30) and the new
|
|
99
|
+
`reply.append`/`reply.remove` (#35) — therefore rolled an empty token forward:
|
|
100
|
+
the first action worked, then the next dispatch from that root was rejected (the
|
|
101
|
+
stale/empty token fails verification) with no error. Fixed by checking private
|
|
102
|
+
methods (`respond_to?(:reactive_token, true)`); a bare Streamable (genuinely no
|
|
103
|
+
token) still skips correctly. The `reactive_collection` builders also now bind the
|
|
104
|
+
**container** as the `token_component`, so an add/remove rolls the list root's
|
|
105
|
+
token forward (the load-bearing part for repeated add/remove). Regression tests
|
|
106
|
+
assert the token is non-empty AND re-verifies, and that a second add using the
|
|
107
|
+
first reply's token succeeds.
|
|
108
|
+
|
|
109
|
+
### Changed
|
|
110
|
+
|
|
111
|
+
- **The browser suite now runs under two real servers — Puma and Falcon.** The
|
|
112
|
+
`system` CI job runs as a matrix (`CAPYBARA_SERVER=puma` and `=falcon`), and a
|
|
113
|
+
new `rake spec:system_servers` task runs both locally, so a reactive round trip
|
|
114
|
+
is proven transport-agnostic across a sync (Puma, thread-pool) and an async
|
|
115
|
+
(Falcon, fiber-per-request) server. `webrick` is **removed** as a test server
|
|
116
|
+
(it isn't a real server) — `falcon` (with `protocol-rack`) replaces it as the
|
|
117
|
+
alternative, registered via `Capybara.register_server(:falcon)`. No production
|
|
118
|
+
dependency change; this is test infrastructure only.
|
|
119
|
+
|
|
120
|
+
- **CI now tests against Ruby 4.0** (added to the test matrix in `main.yml` and
|
|
121
|
+
the pre-release matrix in `release.yml`, alongside 3.2/3.3/3.4; the lint and
|
|
122
|
+
browser-system jobs also run on 4.0 now). Ruby 4.0 shipped December 2025 and is
|
|
123
|
+
the current stable line. The gem requires no code changes for 4.0 — its
|
|
124
|
+
dependencies and the patterns it uses (ObjectSpace::WeakMap, Data.define,
|
|
125
|
+
Thread-locals, the MessageVerifier path) are stable across 3.4 → 4.0, and it
|
|
126
|
+
directly requires none of the gems 4.0 moved from default to bundled.
|
|
127
|
+
`required_ruby_version` stays `>= 3.2.0` (no upper bound, already permits 4.0).
|
|
128
|
+
Verified empirically: the full unit/request/generator suite is green on Ruby
|
|
129
|
+
4.0.5 with zero deprecation or unbundled-gem warnings.
|
|
130
|
+
|
|
131
|
+
### Added
|
|
132
|
+
|
|
133
|
+
- **`reactive_collection` — add/remove-row lists in one declaration (issue #35).**
|
|
134
|
+
An add/remove-row list (line items, tags, comments, a notifications list) is one
|
|
135
|
+
of the most common reactive surfaces, and every one re-implements the same
|
|
136
|
+
orchestration by hand: append the row to the right container, remove it on
|
|
137
|
+
delete, keep a count badge in sync, and swap an empty-state in/out as the list
|
|
138
|
+
crosses 0↔1. `reactive_collection :items, item:, container:, count:, empty:,
|
|
139
|
+
size:` declares that contract **once** on the container component, so each action
|
|
140
|
+
is a single call: `reply.append(:items, model)` (row + count + empty-state
|
|
141
|
+
clear), `reply.prepend(:items, model)`, and `reply.remove(:items, model_or_id)`
|
|
142
|
+
(row + count + empty-state restore). The size badge and empty-state toggle are
|
|
143
|
+
driven by the declared `size:` resolver, **re-counted server-side after the
|
|
144
|
+
mutation** — so they're correct-by-construction (no off-by-one, no client-held
|
|
145
|
+
count, consistent between the first render and the deltas). `count:`/`empty:`/
|
|
146
|
+
`size:` are optional (omit them and only the row stream is emitted), and the new
|
|
147
|
+
builders compose with `.flash`/`.stream` like any other reply. Reply governs the
|
|
148
|
+
actor's HTTP response only; a cross-tab live list still broadcasts the row with
|
|
149
|
+
`broadcast_append_to(..., exclude: reactive_connection_id)`. New
|
|
150
|
+
`Phlex::Reactive::Component::CollectionDefinition`,
|
|
151
|
+
`reactive_collection`/`reactive_collection_def`/`reactive_collection?` macros,
|
|
152
|
+
and `Response.collection_append`/`collection_prepend`/`collection_remove` behind
|
|
153
|
+
the `reply.*` surface.
|
|
154
|
+
|
|
155
|
+
- **`reply.streams` — partial update with a token-only refresh (issue #30).**
|
|
156
|
+
`reply.streams(Totals.update(@item))` emits **exactly** the streams you pass —
|
|
157
|
+
no forced full-self replace — so an action can re-render only part of a
|
|
158
|
+
component (one total cell, one sub-region) while the rest of the DOM, including
|
|
159
|
+
a sibling `<input>` the user is mid-typing in, is left untouched. The signed
|
|
160
|
+
identity token still rolls forward: the endpoint appends a tiny
|
|
161
|
+
`<turbo-stream action="reactive:token">` (new `Streamable#to_stream_token`) that
|
|
162
|
+
carries the fresh `data-reactive-token-value` and is applied by an inert client
|
|
163
|
+
action — a pure attribute write on the root, so the focused field + caret
|
|
164
|
+
survive. This unblocks spreadsheet-like per-field grid editing that the old
|
|
165
|
+
"every reply re-renders the whole component" behavior made unusable (you had to
|
|
166
|
+
reach for `data-turbo-permanent`). `Response.streams(self, *)` is the underlying
|
|
167
|
+
builder; `reply.streams(*)` is the bound form. Backed by a new `render_self:
|
|
168
|
+
false` + `token_component` path in the endpoint — the legacy full-self-replace
|
|
169
|
+
refresh (`reply.replace` / `reply.with`) is unchanged.
|
|
170
|
+
|
|
171
|
+
- **`reply` — the action-reply builder.** Control an action's reply with
|
|
172
|
+
`reply.replace` / `reply.morph` / `reply.update` / `reply.remove` /
|
|
173
|
+
`reply.redirect(url)` / `reply.with(*)`, chaining `.flash` / `.stream` /
|
|
174
|
+
`.also_update` / `.also_replace` as before. `reply` is a subject-bound facade on
|
|
175
|
+
the component, so the two warts of the old form disappear: no `self` to thread
|
|
176
|
+
(`reply.morph`, not `Response.morph(self)`) and no constant to qualify — a
|
|
177
|
+
namespaced component no longer needs a per-file `Response = …` alias, because
|
|
178
|
+
`reply` is a method resolved on the component, not a lexical constant. It
|
|
179
|
+
returns the same immutable value object the endpoint reads, so the return-value
|
|
180
|
+
contract, immutability, and chaining are unchanged. `Phlex::Reactive::Response`
|
|
181
|
+
remains fully functional but is now an internal detail — `reply` is the
|
|
182
|
+
documented surface.
|
|
183
|
+
|
|
184
|
+
- **Morph response — focus-preserving re-render (issue #28).**
|
|
185
|
+
`reply.morph` (and the opt-in `reply.replace(morph: true)`)
|
|
186
|
+
re-renders a component in place via Turbo 8's bundled Idiomorph
|
|
187
|
+
(`<turbo-stream action="replace" method="morph">`) instead of an outerHTML
|
|
188
|
+
swap. The focused `<input>` and its caret survive the save, making per-field
|
|
189
|
+
reactive editing — a "spreadsheet" grid where a debounced save fires while the
|
|
190
|
+
user is still typing/tabbing — actually viable. Backed by the new
|
|
191
|
+
`Streamable#to_stream_morph` primitive and a `morph:` flag on the
|
|
192
|
+
`.replace` / `.broadcast_replace_to` class builders and `#also_replace`
|
|
193
|
+
(live cross-tab updates can morph too). The default everywhere stays the plain
|
|
194
|
+
replace (no `method="morph"` attribute), so existing components are unchanged.
|
|
195
|
+
No new dependency — Idiomorph ships with `turbo-rails >= 2.0`.
|
|
11
196
|
- **Input/select param-binding helpers (issue #23).** `reactive_input(:value,
|
|
12
197
|
…)` and `reactive_select(:status, …) { … }` render a form control already bound
|
|
13
198
|
to an action param — no hand-written `name: "value"` magic string to forget
|
|
@@ -59,6 +244,41 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
59
244
|
trip still goes through the per-component queue so token threading holds.
|
|
60
245
|
Omitting `debounce:` keeps the immediate-dispatch default.
|
|
61
246
|
|
|
247
|
+
### Performance
|
|
248
|
+
|
|
249
|
+
- **Re-render is ~2× faster with ~half the allocations.** `render_component` now
|
|
250
|
+
renders through phlex-rails' lightweight `#render_in` against a memoized
|
|
251
|
+
off-request view context, instead of `ActionController.renderer.render` (which
|
|
252
|
+
dragged in ActionView's `TemplateRenderer`/`LookupContext`/log subscriber).
|
|
253
|
+
Measured same-machine before/after: `render_component` 6.99k→14.1k i/s (212→99
|
|
254
|
+
obj/call); `to_stream_replace` 4.60k→8.00k i/s (331→191 obj/call). HTML is
|
|
255
|
+
byte-identical and the full Rails helper set (`dom_id`/`url_for`/`t`/CSRF) still
|
|
256
|
+
works. The biggest win is on the **broadcast** path (N subscribers = N renders,
|
|
257
|
+
no HTTP to amortize against); at the full-request level the Rails stack + DB
|
|
258
|
+
dominate, so request throughput is roughly unchanged.
|
|
259
|
+
|
|
260
|
+
- **View context, Turbo `TagBuilder`, and flash builder are memoized** per
|
|
261
|
+
component class (the comment used to claim this; now it's true). Keyed on the
|
|
262
|
+
configured renderer's identity so swapping `Phlex::Reactive.renderer` rebuilds,
|
|
263
|
+
and reset on Rails code reload (`config.to_prepare`) so a reloaded controller
|
|
264
|
+
is never served stale.
|
|
265
|
+
|
|
266
|
+
- **Smaller per-render allocations on the token path.** `reactive_token`
|
|
267
|
+
precomputes its ivar symbols + state string-keys per class (14→11 obj/call,
|
|
268
|
+
state-backed), and `on(:action)` skips re-serializing an empty params hash
|
|
269
|
+
(6→5 obj/call). The bracket-key regex in param coercion is hoisted to a frozen
|
|
270
|
+
constant.
|
|
271
|
+
|
|
272
|
+
- **Client: the page-stable action path is resolved once per controller** instead
|
|
273
|
+
of a `querySelector` per dispatch. CSRF token and pgbus connection id stay live
|
|
274
|
+
(they can rotate).
|
|
275
|
+
|
|
276
|
+
- **Benchmark harness + CI report.** `rake bench` (micro: render/token/coerce) and
|
|
277
|
+
`rake bench:request` (end-to-end via derailed) measure the hot paths; a CI
|
|
278
|
+
`bench` job runs them on every PR and uploads the report as an artifact
|
|
279
|
+
(run-and-report, not a hard gate). See `docs/performance.md` and the `/perf`
|
|
280
|
+
command.
|
|
281
|
+
|
|
62
282
|
### Fixed
|
|
63
283
|
|
|
64
284
|
- **Model-scoped form fields feed a nested param (issue #21).** A Rails
|
data/README.md
CHANGED
|
@@ -191,8 +191,8 @@ data-controller="reactive" but the reactive controller never connected …`).
|
|
|
191
191
|
│ rebuild component (record from DB)
|
|
192
192
|
│ run the whitelisted action
|
|
193
193
|
│ re-render → <turbo-stream replace id> (default; an action
|
|
194
|
-
│ may return
|
|
195
|
-
└──────── Turbo
|
|
194
|
+
│ may return reply.<verb> — see "Controlling the action's reply")
|
|
195
|
+
└──────── Turbo applies it in ◀──────────────────────────────┘
|
|
196
196
|
|
|
197
197
|
...and for OTHER tabs/users:
|
|
198
198
|
model change → Component.broadcast_replace_to(stream) → pgbus SSE → same morph
|
|
@@ -292,11 +292,11 @@ The cross-tab chat in ~60 lines of Ruby (and zero JS) is the showcase — see
|
|
|
292
292
|
| Method | Use |
|
|
293
293
|
|---|---|
|
|
294
294
|
| `#id` (you implement) | Stable DOM id == Turbo Stream target. Must match the root element's `id`. |
|
|
295
|
-
| `.replace(model = nil, **opts)` | `<turbo-stream action=replace target=id>` of a freshly built component |
|
|
295
|
+
| `.replace(model = nil, morph: false, **opts)` | `<turbo-stream action=replace target=id>` of a freshly built component; `morph: true` adds `method="morph"` |
|
|
296
296
|
| `.update` / `.append(target:)` / `.prepend(target:)` / `.remove` | The other Turbo Stream actions |
|
|
297
|
-
| `.broadcast_replace_to(*streamables, model:)` | Broadcast a replace over the stream transport (pgbus SSE / Action Cable) |
|
|
297
|
+
| `.broadcast_replace_to(*streamables, model:, morph: false)` | Broadcast a replace over the stream transport (pgbus SSE / Action Cable); `morph: true` morphs in place |
|
|
298
298
|
| `.broadcast_append_to(*streamables, target:, model:)` / `_update_` / `_prepend_` / `_remove_` | The broadcast variants |
|
|
299
|
-
| `#to_stream_replace` / `#to_stream_update` / `#to_stream_remove` | Stream the *already-built* instance (used internally after an action / by `
|
|
299
|
+
| `#to_stream_replace` / `#to_stream_morph` / `#to_stream_update` / `#to_stream_remove` | Stream the *already-built* instance (used internally after an action / by `reply`); `#to_stream_morph` morphs in place |
|
|
300
300
|
|
|
301
301
|
Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
302
302
|
|
|
@@ -313,9 +313,52 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
313
313
|
| `reactive_input(:param, **attrs)` / `reactive_select(:param, **attrs)` | Render a control already bound to an action param (no magic `name:`). |
|
|
314
314
|
| `reactive_field(:param, **attrs)` | The attribute hash behind the above — spread onto any control. |
|
|
315
315
|
| `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
|
|
316
|
+
| `reactive_collection :name, item:, container:, count:, empty:, size:` | Declare an add/remove-row list once; actions call `reply.append`/`prepend`/`remove`. See [Reactive collections](#reactive-collections-addremove-rows--count--empty-state). |
|
|
317
|
+
| `reply.replace` / `.morph` / `.update` / `.remove` / `.redirect(url)` / `.with(*)` | Return from an action to control the reply (flash, remove, redirect, multi-stream). See [Controlling the action's reply](#reply--controlling-the-actions-reply). |
|
|
318
|
+
| `reply.append(name, model)` / `.prepend(...)` / `.remove(name, model)` | Add/remove a row in a declared `reactive_collection` (row + count + empty-state in one reply). |
|
|
319
|
+
|
|
320
|
+
Param types: `:string` (default), `:integer`, `:float`, `:boolean`, `:file`.
|
|
321
|
+
Anything not in the schema is dropped before reaching your method.
|
|
322
|
+
|
|
323
|
+
**File uploads (`:file`).** Declare `:file` (or `[:file]` for multiple) to accept
|
|
324
|
+
an uploaded file in a reactive action — attach a document/receipt/image to the
|
|
325
|
+
record without dropping out to a bespoke controller. When the reactive root holds
|
|
326
|
+
a populated `<input type="file">`, the client sends the action as multipart
|
|
327
|
+
`FormData` (instead of JSON) — `token` + `act` as fields, scalar params as fields,
|
|
328
|
+
any nested/array params bracket-expanded into `params[key][sub]` /
|
|
329
|
+
`params[key][index]` fields (the same Rails-form shape, so a JSON body and a
|
|
330
|
+
multipart body coerce identically — #39), and the file(s) appended; the endpoint
|
|
331
|
+
coerces `:file` to the `ActionDispatch::Http::UploadedFile`, passed through
|
|
332
|
+
untouched. A non-file value sent to a `:file` param is dropped (the keyword
|
|
333
|
+
default applies — never a fabricated file). Token threading and the
|
|
334
|
+
re-render/morph are identical; only the request encoding changes when a file is
|
|
335
|
+
present.
|
|
316
336
|
|
|
317
|
-
|
|
318
|
-
|
|
337
|
+
```ruby
|
|
338
|
+
reactive_record :document
|
|
339
|
+
action :upload, params: { file: :file, caption: :string } # single (has_one_attached)
|
|
340
|
+
action :upload_pages, params: { pages: [:file] } # multiple (has_many_attached)
|
|
341
|
+
|
|
342
|
+
def upload(file: nil, caption: nil)
|
|
343
|
+
@document.file.attach(file) if file
|
|
344
|
+
@document.update!(title: caption) if caption.present?
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def view_template
|
|
348
|
+
form(**on(:upload, event: "submit")) do
|
|
349
|
+
input(type: "file", name: "file")
|
|
350
|
+
input(name: "caption")
|
|
351
|
+
button(type: "submit") { "Upload" }
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
> **One multipart caveat:** `FormData` can't carry an *empty* array or hash, so on
|
|
357
|
+
> the multipart (file-present) path an empty `[]`/`{}` param is **omitted** and the
|
|
358
|
+
> action's keyword default applies — it does **not** arrive as an explicit empty
|
|
359
|
+
> collection the way it does over JSON. If you rely on sending `tags: []` to clear
|
|
360
|
+
> a collection, send that action *without* a file (the JSON path). A non-empty
|
|
361
|
+
> nested/array param rides along fine next to a file.
|
|
319
362
|
|
|
320
363
|
**Array & nested params.** Wrap a type in an array for an array param, or a hash
|
|
321
364
|
schema in an array for Rails-style nested attributes — so one reactive action can
|
|
@@ -422,52 +465,139 @@ end
|
|
|
422
465
|
`nested_attributes(:address, address)` returns the id-merged hash without
|
|
423
466
|
updating, if you need to combine it with other attributes.
|
|
424
467
|
|
|
425
|
-
### `
|
|
468
|
+
### `reply` — controlling the action's reply
|
|
426
469
|
|
|
427
|
-
By default an action re-renders its component in place. **
|
|
428
|
-
`
|
|
429
|
-
cross-tab updates still use
|
|
430
|
-
Returning anything else
|
|
470
|
+
By default an action re-renders its component in place. To do more, **return**
|
|
471
|
+
`reply.<verb>` — a subject-bound builder available in every component. It governs
|
|
472
|
+
only the actor's HTTP reply (cross-tab updates still use
|
|
473
|
+
`broadcast_*_to(..., exclude: reactive_connection_id)`). Returning anything else
|
|
474
|
+
keeps the default, so existing actions are unaffected.
|
|
431
475
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
476
|
+
`reply` reads cleanly: the component is the implicit subject (no `self` to
|
|
477
|
+
thread) and there's no constant to qualify (it's a method, so a namespaced
|
|
478
|
+
component needs no alias):
|
|
435
479
|
|
|
436
480
|
```ruby
|
|
437
|
-
Response = Phlex::Reactive::Response # or qualify each call below
|
|
438
|
-
|
|
439
481
|
def rename(title:)
|
|
440
|
-
return
|
|
441
|
-
|
|
482
|
+
return reply.replace.flash(:error, @todo.errors.full_messages.to_sentence) unless @todo.update(title:)
|
|
483
|
+
reply.replace
|
|
442
484
|
end
|
|
443
485
|
|
|
444
|
-
def approve = (@row.approve!;
|
|
445
|
-
def publish = (@article.publish!;
|
|
446
|
-
def add(item:) =
|
|
486
|
+
def approve = (@row.approve!; reply.remove) # drop the element
|
|
487
|
+
def publish = (@article.publish!; reply.redirect(article_url(@article))) # slug changed → Turbo.visit
|
|
488
|
+
def add(item:) = reply.replace.stream(Totals.update(@order)) # multi-stream
|
|
489
|
+
|
|
490
|
+
# Per-field reactive editing (a "spreadsheet" grid): a debounced save fires
|
|
491
|
+
# while the user is still typing/tabbing. Morph in place so the focused <input>
|
|
492
|
+
# and its in-progress value survive the re-render (issue #28). Note the action is
|
|
493
|
+
# named `update`, yet `reply.morph` is unambiguous — the verb is on `reply`:
|
|
494
|
+
def update(name:) = (@row.update!(name:); reply.morph)
|
|
447
495
|
|
|
448
496
|
# Re-render a COMPANION element (a heading mirroring the edited name) alongside self:
|
|
449
|
-
def rename(value:) = (@account.update!(name: value);
|
|
497
|
+
def rename(value:) = (@account.update!(name: value); reply.replace.also_update("page_heading", html: @account.name))
|
|
498
|
+
|
|
499
|
+
# Update ONLY part of the component (issue #30): re-stream just the total cell,
|
|
500
|
+
# NOT the whole row. reply.streams emits exactly your streams plus a tiny
|
|
501
|
+
# token-only refresh — no full-self replace — so a sibling <input> the user is
|
|
502
|
+
# mid-typing in is never torn down. The signed token still rolls forward.
|
|
503
|
+
def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams(Totals.update(@item)))
|
|
450
504
|
```
|
|
451
505
|
|
|
452
506
|
| Builder | Reply |
|
|
453
507
|
|---|---|
|
|
454
|
-
| `
|
|
508
|
+
| `reply.replace` / `reply.update` | re-render in place (default; `replace` is an outerHTML swap, `update` morphs inner HTML) |
|
|
509
|
+
| `reply.morph` / `reply.replace(morph: true)` | re-render in place via Idiomorph (`method="morph"`) — preserves the focused `<input>` + caret; for per-field reactive editing (issue #28) |
|
|
455
510
|
| `.also_update(target, html:)` | also re-render a companion element by DOM id; `html` is a plain string (escaped) or a Phlex component |
|
|
456
|
-
| `.also_replace(component)` | also re-render another Streamable component, targeting its own `#id` |
|
|
511
|
+
| `.also_replace(component, morph: false)` | also re-render another Streamable component, targeting its own `#id`; `morph: true` morphs it in place |
|
|
457
512
|
| `.flash(level, content, target: …)` | append a flash; `content` is a plain string (escaped) or a Phlex component (off-request — no Rails `flash`); target defaults to `Phlex::Reactive.flash_target` (`"flash"`) |
|
|
458
|
-
| `
|
|
459
|
-
| `
|
|
460
|
-
| `
|
|
513
|
+
| `reply.remove` | remove the element (backed by `Streamable#to_stream_remove`) |
|
|
514
|
+
| `reply.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
|
|
515
|
+
| `reply.streams(*streams)` | **partial update** — emit exactly these streams (no full-self replace) + a tiny token-only refresh, so live inputs survive; for per-field grid editing (issue #30) |
|
|
516
|
+
| `reply.with(*streams)` / `#stream(*more)` | multi-stream (self re-render still injected for the token) |
|
|
461
517
|
|
|
462
518
|
`.flash`/`.stream`/`.also_*` are additive on a self-replace, so the component's
|
|
463
|
-
signed token always refreshes.
|
|
464
|
-
|
|
519
|
+
signed token always refreshes. **`reply.streams`** is the exception that proves
|
|
520
|
+
the rule: it deliberately skips the full-self replace (so your hand-built streams
|
|
521
|
+
update only the targets you name) and refreshes the token via a tiny inert
|
|
522
|
+
`reactive:token` stream instead — the token rolls forward without re-rendering
|
|
523
|
+
(and clobbering) the component's live inputs.
|
|
524
|
+
|
|
525
|
+
> **Under the hood.** `reply.<verb>` returns a `Phlex::Reactive::Response` — the
|
|
526
|
+
> immutable value object the endpoint reads. You can build one directly
|
|
527
|
+
> (`Phlex::Reactive::Response.replace(self)`) and it still works, but `reply` is
|
|
528
|
+
> the preferred surface; treat `Response` as an internal detail.
|
|
465
529
|
> **`html:`/`content` escaping.** A plain string is **HTML-escaped** by Turbo, so
|
|
466
530
|
> `html: @account.name` is safe even for user-supplied values. To emit intentional
|
|
467
531
|
> markup, pass a **Phlex component** (`html: Heading.new(name: @record.name)`) —
|
|
468
532
|
> rendered and auto-escaped through the renderer — or an `html_safe` string for
|
|
469
533
|
> raw HTML you control.
|
|
470
534
|
|
|
535
|
+
### Reactive collections (add/remove rows + count + empty-state)
|
|
536
|
+
|
|
537
|
+
An add/remove-row list — line items, attachments, tags, comments, a
|
|
538
|
+
notifications list — is one of the most common reactive surfaces, and every one
|
|
539
|
+
re-implements the same orchestration by hand: append the row to the right
|
|
540
|
+
container, remove it on delete, keep a **count badge** in sync, and swap an
|
|
541
|
+
**empty-state** in/out as the list crosses 0↔1. `reactive_collection` declares
|
|
542
|
+
that contract **once** on the container so each action is a single call.
|
|
543
|
+
|
|
544
|
+
Declare the collection on the container component, then `reply.append` /
|
|
545
|
+
`reply.prepend` / `reply.remove` in the actions:
|
|
546
|
+
|
|
547
|
+
```ruby
|
|
548
|
+
class NotificationsList < ApplicationComponent
|
|
549
|
+
include Phlex::Reactive::Streamable
|
|
550
|
+
include Phlex::Reactive::Component
|
|
551
|
+
|
|
552
|
+
reactive_collection :notifications,
|
|
553
|
+
item: NotificationRow, # the per-row Streamable component
|
|
554
|
+
container: "notifications", # the DOM id rows live in
|
|
555
|
+
count: "notifications-count", # optional companion id (the size badge)
|
|
556
|
+
empty: NotificationsEmpty, # optional empty-state component
|
|
557
|
+
size: -> { Todo.count } # resolves the live size (re-counted, never client state)
|
|
558
|
+
|
|
559
|
+
action :add, params: {title: :string}
|
|
560
|
+
action :dismiss, params: {id: :integer}
|
|
561
|
+
|
|
562
|
+
def add(title:)
|
|
563
|
+
todo = Todo.create!(title:)
|
|
564
|
+
reply.append(:notifications, todo) # append row + bump count + clear empty-state
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def dismiss(id:)
|
|
568
|
+
Todo.find(id).destroy!
|
|
569
|
+
reply.remove(:notifications, id) # remove row + bump count + restore empty-state at 0
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# view_template renders the count, the container <ul>, and the empty-state on
|
|
573
|
+
# first paint — the same components the helper streams in/out on each delta.
|
|
574
|
+
end
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
| Builder | Reply (one `Response`) |
|
|
578
|
+
|---|---|
|
|
579
|
+
| `reply.append(name, model)` | append the row into the container + update the count + remove the empty-state when the list crosses 0→1 |
|
|
580
|
+
| `reply.prepend(name, model)` | as `append`, but the row goes to the top |
|
|
581
|
+
| `reply.remove(name, model)` | remove the row by its `dom_id` + update the count + append the empty-state back when the list crosses →0 |
|
|
582
|
+
|
|
583
|
+
- **`size:` is the source of truth** — it's *re-counted* server-side after the
|
|
584
|
+
mutation, so the badge and the empty-state are correct-by-construction (no
|
|
585
|
+
off-by-one, no client-held count). `count:`, `empty:`, and `size:` are all
|
|
586
|
+
optional: omit them and only the row stream is emitted.
|
|
587
|
+
- **Repeated add/remove just works** — each reply rolls the **container's** signed
|
|
588
|
+
token forward (via the inert `reactive:token` refresh), so the second click from
|
|
589
|
+
the list root is accepted. Without this an add/remove list would be add-once-only
|
|
590
|
+
(correct on the first click, silently rejected after); the helper bakes the
|
|
591
|
+
refresh in so you never hit it.
|
|
592
|
+
- **`remove` takes the record or its `dom_id` string** — a just-destroyed
|
|
593
|
+
ActiveRecord still answers `dom_id` correctly, so `reply.remove(:items, todo)`
|
|
594
|
+
works; pass the raw id only if your row `#id` matches `ActiveRecord::RecordIdentifier`.
|
|
595
|
+
- **Reply governs the actor's HTTP response only.** For a *cross-tab* live list
|
|
596
|
+
(other viewers see the row appear) keep broadcasting the row with
|
|
597
|
+
`NotificationRow.broadcast_append_to(..., exclude: reactive_connection_id)` —
|
|
598
|
+
`reactive_collection` is the per-actor add/remove + count + empty-state wrapper,
|
|
599
|
+
not a replacement for the broadcast.
|
|
600
|
+
|
|
471
601
|
### Configuration (`config/initializers/phlex_reactive.rb`)
|
|
472
602
|
|
|
473
603
|
```ruby
|
|
@@ -638,6 +768,7 @@ See [docs/broadcasting.md](docs/broadcasting.md) and
|
|
|
638
768
|
- [Broadcasting & live updates](docs/broadcasting.md)
|
|
639
769
|
- [Transport: pgbus vs Action Cable](docs/transport-pgbus.md)
|
|
640
770
|
- [Testing reactive components](docs/testing.md)
|
|
771
|
+
- [Performance & benchmarking](docs/performance.md)
|
|
641
772
|
- Examples: [counter](docs/examples/counter.md) ·
|
|
642
773
|
[chat](docs/examples/chat.md) · [todo list](docs/examples/todo_list.md) ·
|
|
643
774
|
[inline edit](docs/examples/inline_edit.md) ·
|