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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6b8ebca93e744e1c2fa3797de877cefc732db8a6cb950d95874c9e00e4dee76
4
- data.tar.gz: 119b1373f8ade989791845fc9cf3206f7786eb97f6009faaabfadb1485c82b44
3
+ metadata.gz: 8811ee6a3c0f044fd7c9d16843a641296b33b8fcb5872f5bc8822257f837148e
4
+ data.tar.gz: 9398a1c088680e83b8bed3398342345ceb817703183df0985a1948e29df021cf
5
5
  SHA512:
6
- metadata.gz: ee11a3bf0ac506cdbde2e97e9e47bad436a16050c0b150cd5340bbff9ae846586487bbf6a2bbf028ff20c44faa64f2e57953ddf15adcbafc82491d5e15a9f22f
7
- data.tar.gz: 3bd5cd4a362ed18f59da8bf375474aa8beabd5cfdd0d62572d5fce043318c057b01afcf3942acaec30417dc16fd3b3d8d9e6ef9f7ec524f55cf411cc168b8003
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 a Response — see "Controlling the action's reply")
195
- └──────── Turbo morphs it in ◀───────────────────────────────┘
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 `Response`) |
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
- Param types: `:string` (default), `:integer`, `:float`, `:boolean`. Anything not
318
- in the schema is dropped before reaching your method.
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
- ### `Phlex::Reactive::Response` — controlling the action's reply
468
+ ### `reply` — controlling the action's reply
426
469
 
427
- By default an action re-renders its component in place. **Return** a
428
- `Phlex::Reactive::Response` to do more (it governs only the actor's HTTP reply —
429
- cross-tab updates still use `broadcast_*_to(..., exclude: reactive_connection_id)`).
430
- Returning anything else keeps the default, so existing actions are unaffected.
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
- The snippets below alias the constant for brevity (`Response.replace(self)` won't
433
- resolve to `Phlex::Reactive::Response` inside a namespaced component fully
434
- qualify it, or add the alias shown):
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 Response.replace(self).flash(:error, @todo.errors.full_messages.to_sentence) unless @todo.update(title:)
441
- Response.replace(self)
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!; Response.remove(self)) # drop the element
445
- def publish = (@article.publish!; Response.redirect(article_url(@article))) # slug changed → Turbo.visit
446
- def add(item:) = Response.replace(self).stream(Totals.update(@order)) # multi-stream
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); Response.replace(self).also_update("page_heading", html: @account.name))
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
- | `Response.replace(self)` / `.update(self)` | re-render in place (explicit default) |
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
- | `Response.remove(self)` | remove the element (backed by `Streamable#to_stream_remove`) |
459
- | `Response.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
460
- | `Response.with(*streams)` / `#stream(*more)` | multi-stream |
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) ·