phlex-reactive 0.2.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6b8ebca93e744e1c2fa3797de877cefc732db8a6cb950d95874c9e00e4dee76
4
- data.tar.gz: 119b1373f8ade989791845fc9cf3206f7786eb97f6009faaabfadb1485c82b44
3
+ metadata.gz: 7232501319d17c45c8907195117f5af59d8b7a4ff9c8d5efb2c0c8248350041e
4
+ data.tar.gz: 783cd0758a5a51cead67f6f1ea2c8bf23245f62e49a38ea0831328b68b9d0b2c
5
5
  SHA512:
6
- metadata.gz: ee11a3bf0ac506cdbde2e97e9e47bad436a16050c0b150cd5340bbff9ae846586487bbf6a2bbf028ff20c44faa64f2e57953ddf15adcbafc82491d5e15a9f22f
7
- data.tar.gz: 3bd5cd4a362ed18f59da8bf375474aa8beabd5cfdd0d62572d5fce043318c057b01afcf3942acaec30417dc16fd3b3d8d9e6ef9f7ec524f55cf411cc168b8003
6
+ metadata.gz: a673a7253c99e7b6fccb5228014a47d2918cda2995de79afdb6545adb89a8af8ab99812569455bc7f206c12d7c7efb710ff90d628d767da012ba5deb690e5ab7
7
+ data.tar.gz: cf68ac7ff60925c73aa0de543665b60d85d8254bada63a40c1786a6a3e7ee3bfceaad123c91daf2f7fec18de8aab04ab61d2ee165b5e0ad241b451ce60c24769
data/CHANGELOG.md CHANGED
@@ -8,6 +8,47 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **`reply.streams` — partial update with a token-only refresh (issue #30).**
12
+ `reply.streams(Totals.update(@item))` emits **exactly** the streams you pass —
13
+ no forced full-self replace — so an action can re-render only part of a
14
+ component (one total cell, one sub-region) while the rest of the DOM, including
15
+ a sibling `<input>` the user is mid-typing in, is left untouched. The signed
16
+ identity token still rolls forward: the endpoint appends a tiny
17
+ `<turbo-stream action="reactive:token">` (new `Streamable#to_stream_token`) that
18
+ carries the fresh `data-reactive-token-value` and is applied by an inert client
19
+ action — a pure attribute write on the root, so the focused field + caret
20
+ survive. This unblocks spreadsheet-like per-field grid editing that the old
21
+ "every reply re-renders the whole component" behavior made unusable (you had to
22
+ reach for `data-turbo-permanent`). `Response.streams(self, *)` is the underlying
23
+ builder; `reply.streams(*)` is the bound form. Backed by a new `render_self:
24
+ false` + `token_component` path in the endpoint — the legacy full-self-replace
25
+ refresh (`reply.replace` / `reply.with`) is unchanged.
26
+
27
+ - **`reply` — the action-reply builder.** Control an action's reply with
28
+ `reply.replace` / `reply.morph` / `reply.update` / `reply.remove` /
29
+ `reply.redirect(url)` / `reply.with(*)`, chaining `.flash` / `.stream` /
30
+ `.also_update` / `.also_replace` as before. `reply` is a subject-bound facade on
31
+ the component, so the two warts of the old form disappear: no `self` to thread
32
+ (`reply.morph`, not `Response.morph(self)`) and no constant to qualify — a
33
+ namespaced component no longer needs a per-file `Response = …` alias, because
34
+ `reply` is a method resolved on the component, not a lexical constant. It
35
+ returns the same immutable value object the endpoint reads, so the return-value
36
+ contract, immutability, and chaining are unchanged. `Phlex::Reactive::Response`
37
+ remains fully functional but is now an internal detail — `reply` is the
38
+ documented surface.
39
+
40
+ - **Morph response — focus-preserving re-render (issue #28).**
41
+ `reply.morph` (and the opt-in `reply.replace(morph: true)`)
42
+ re-renders a component in place via Turbo 8's bundled Idiomorph
43
+ (`<turbo-stream action="replace" method="morph">`) instead of an outerHTML
44
+ swap. The focused `<input>` and its caret survive the save, making per-field
45
+ reactive editing — a "spreadsheet" grid where a debounced save fires while the
46
+ user is still typing/tabbing — actually viable. Backed by the new
47
+ `Streamable#to_stream_morph` primitive and a `morph:` flag on the
48
+ `.replace` / `.broadcast_replace_to` class builders and `#also_replace`
49
+ (live cross-tab updates can morph too). The default everywhere stays the plain
50
+ replace (no `method="morph"` attribute), so existing components are unchanged.
51
+ No new dependency — Idiomorph ships with `turbo-rails >= 2.0`.
11
52
  - **Input/select param-binding helpers (issue #23).** `reactive_input(:value,
12
53
  …)` and `reactive_select(:status, …) { … }` render a form control already bound
13
54
  to an action param — no hand-written `name: "value"` magic string to forget
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,6 +313,7 @@ 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
+ | `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). |
316
317
 
317
318
  Param types: `:string` (default), `:integer`, `:float`, `:boolean`. Anything not
318
319
  in the schema is dropped before reaching your method.
@@ -422,46 +423,68 @@ end
422
423
  `nested_attributes(:address, address)` returns the id-merged hash without
423
424
  updating, if you need to combine it with other attributes.
424
425
 
425
- ### `Phlex::Reactive::Response` — controlling the action's reply
426
+ ### `reply` — controlling the action's reply
426
427
 
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.
428
+ By default an action re-renders its component in place. To do more, **return**
429
+ `reply.<verb>` a subject-bound builder available in every component. It governs
430
+ only the actor's HTTP reply (cross-tab updates still use
431
+ `broadcast_*_to(..., exclude: reactive_connection_id)`). Returning anything else
432
+ keeps the default, so existing actions are unaffected.
431
433
 
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):
434
+ `reply` reads cleanly: the component is the implicit subject (no `self` to
435
+ thread) and there's no constant to qualify (it's a method, so a namespaced
436
+ component needs no alias):
435
437
 
436
438
  ```ruby
437
- Response = Phlex::Reactive::Response # or qualify each call below
438
-
439
439
  def rename(title:)
440
- return Response.replace(self).flash(:error, @todo.errors.full_messages.to_sentence) unless @todo.update(title:)
441
- Response.replace(self)
440
+ return reply.replace.flash(:error, @todo.errors.full_messages.to_sentence) unless @todo.update(title:)
441
+ reply.replace
442
442
  end
443
443
 
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
444
+ def approve = (@row.approve!; reply.remove) # drop the element
445
+ def publish = (@article.publish!; reply.redirect(article_url(@article))) # slug changed → Turbo.visit
446
+ def add(item:) = reply.replace.stream(Totals.update(@order)) # multi-stream
447
+
448
+ # Per-field reactive editing (a "spreadsheet" grid): a debounced save fires
449
+ # while the user is still typing/tabbing. Morph in place so the focused <input>
450
+ # and its in-progress value survive the re-render (issue #28). Note the action is
451
+ # named `update`, yet `reply.morph` is unambiguous — the verb is on `reply`:
452
+ def update(name:) = (@row.update!(name:); reply.morph)
447
453
 
448
454
  # 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))
455
+ def rename(value:) = (@account.update!(name: value); reply.replace.also_update("page_heading", html: @account.name))
456
+
457
+ # Update ONLY part of the component (issue #30): re-stream just the total cell,
458
+ # NOT the whole row. reply.streams emits exactly your streams plus a tiny
459
+ # token-only refresh — no full-self replace — so a sibling <input> the user is
460
+ # mid-typing in is never torn down. The signed token still rolls forward.
461
+ def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams(Totals.update(@item)))
450
462
  ```
451
463
 
452
464
  | Builder | Reply |
453
465
  |---|---|
454
- | `Response.replace(self)` / `.update(self)` | re-render in place (explicit default) |
466
+ | `reply.replace` / `reply.update` | re-render in place (default; `replace` is an outerHTML swap, `update` morphs inner HTML) |
467
+ | `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
468
  | `.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` |
469
+ | `.also_replace(component, morph: false)` | also re-render another Streamable component, targeting its own `#id`; `morph: true` morphs it in place |
457
470
  | `.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 |
471
+ | `reply.remove` | remove the element (backed by `Streamable#to_stream_remove`) |
472
+ | `reply.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
473
+ | `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) |
474
+ | `reply.with(*streams)` / `#stream(*more)` | multi-stream (self re-render still injected for the token) |
461
475
 
462
476
  `.flash`/`.stream`/`.also_*` are additive on a self-replace, so the component's
463
- signed token always refreshes.
464
-
477
+ signed token always refreshes. **`reply.streams`** is the exception that proves
478
+ the rule: it deliberately skips the full-self replace (so your hand-built streams
479
+ update only the targets you name) and refreshes the token via a tiny inert
480
+ `reactive:token` stream instead — the token rolls forward without re-rendering
481
+ (and clobbering) the component's live inputs.
482
+
483
+ > **Under the hood.** `reply.<verb>` returns a `Phlex::Reactive::Response` — the
484
+ > immutable value object the endpoint reads. You can build one directly
485
+ > (`Phlex::Reactive::Response.replace(self)`) and it still works, but `reply` is
486
+ > the preferred surface; treat `Response` as an internal detail.
487
+ > **`html:`/`content` escaping.** A plain string is **HTML-escaped** by Turbo, so
465
488
  > **`html:`/`content` escaping.** A plain string is **HTML-escaped** by Turbo, so
466
489
  > `html: @account.name` is safe even for user-supplied values. To emit intentional
467
490
  > markup, pass a **Phlex component** (`html: Heading.new(name: @record.name)`) —
@@ -78,6 +78,19 @@ module Phlex
78
78
  return [redirect_stream(result.redirect_url)] if result.redirect?
79
79
 
80
80
  streams = result.streams
81
+
82
+ # Partial update (Response.streams / reply.streams, issue #30): the action
83
+ # re-rendered only PART of the component and opted out of the full-self
84
+ # replace. Append a tiny token-only stream so the signed token still rolls
85
+ # forward WITHOUT re-rendering (and clobbering) the live inputs. Skip it
86
+ # only if the caller already supplied THIS component's token (idempotent) —
87
+ # the dedupe is scoped to the actor's own target, NOT a global substring,
88
+ # so a partial reply that legitimately includes another reactive
89
+ # component's stream (which carries its OWN token) still refreshes ours.
90
+ if result.refresh_token? && !carries_token_for?(streams, result.token_component)
91
+ return [*streams, result.token_component.to_stream_token]
92
+ end
93
+
81
94
  # Guarantee the component's signed identity token is refreshed unless the
82
95
  # Response opted out (remove/redirect navigate away — handled above). The
83
96
  # client reads the next token from the response body (#extractToken), so
@@ -94,6 +107,17 @@ module Phlex
94
107
  streams
95
108
  end
96
109
 
110
+ # True when one of `streams` already carries a fresh token TARGETING this
111
+ # component — i.e. the caller hand-built the actor's own token-bearing
112
+ # stream, so appending to_stream_token would double it. Scoped to the
113
+ # component's target id (not a global substring) so a sibling component's
114
+ # stream, which carries its OWN token for a DIFFERENT target, doesn't fool
115
+ # us into skipping this component's refresh.
116
+ def carries_token_for?(streams, component)
117
+ target = %(target="#{ERB::Util.html_escape(component.id)}")
118
+ streams.any? { |s| s.include?("data-reactive-token-value") && s.include?(target) }
119
+ end
120
+
97
121
  # A 200 turbo-stream carrying a namespaced custom action the client turns
98
122
  # into Turbo.visit — NOT an HTTP 3xx, which the client hard-bails on
99
123
  # (response.redirected). The matching client handler is registered in
@@ -4,7 +4,8 @@ import { Controller } from "@hotwired/stimulus"
4
4
  // replaces the per-feature Stimulus controllers you'd otherwise hand-write
5
5
  // for interactive components. A component declares its actions in Ruby (via
6
6
  // Phlex::Reactive::Component); this controller binds DOM events to a single
7
- // HTTP round trip and lets Turbo morph the re-rendered component back in.
7
+ // HTTP round trip and lets Turbo apply the re-rendered component back in
8
+ // (replace by default; method="morph" — Response.morph — preserves focus).
8
9
  //
9
10
  // Wire format (client -> server), POST <action path>, turbo-stream Accept:
10
11
  // { token: "<signed identity>", act: "<action>", params: {...} }
@@ -31,9 +32,38 @@ export function registerReactiveVisit() {
31
32
  if (url) window.Turbo.visit(url, { action: "advance" })
32
33
  }
33
34
  }
35
+
36
+ // Custom turbo-stream action: a TOKEN-ONLY refresh (issue #30). A partial
37
+ // update (Response.streams / reply.streams) re-renders only PART of a component
38
+ // — so there's no full-self replace to carry the next signed token. The server
39
+ // instead emits `<turbo-stream action="reactive:token" target="<id>"
40
+ // data-reactive-token-value="<fresh>">`. #perform's #extractToken already reads
41
+ // the token out of the response body for the NEXT queued request; this handler
42
+ // keeps the DOM in sync too, writing the attribute onto the root element so the
43
+ // `tokenValue` fallback stays fresh. It's a pure attribute set — no node is
44
+ // replaced — so a focused <input> + caret survive (the whole point: update a
45
+ // total cell without tearing down the field the user is typing in).
46
+ export function registerReactiveToken() {
47
+ const actions = window.Turbo?.StreamActions
48
+ if (!actions || actions["reactive:token"]) return
49
+ actions["reactive:token"] = function () {
50
+ const token = this.getAttribute("data-reactive-token-value")
51
+ const target = this.getAttribute("target")
52
+ if (!token || !target) return
53
+ const el = document.getElementById(target)
54
+ // Stimulus reads the token via the `token` value -> data-reactive-token-value.
55
+ if (el) el.setAttribute("data-reactive-token-value", token)
56
+ }
57
+ }
58
+
59
+ export function registerReactiveActions() {
60
+ registerReactiveVisit()
61
+ registerReactiveToken()
62
+ }
63
+
34
64
  if (typeof window !== "undefined") {
35
- if (window.Turbo) registerReactiveVisit()
36
- else document.addEventListener("turbo:load", registerReactiveVisit, { once: true })
65
+ if (window.Turbo) registerReactiveActions()
66
+ else document.addEventListener("turbo:load", registerReactiveActions, { once: true })
37
67
  }
38
68
 
39
69
  // --- Registration guard (issue #26 part 2) -------------------------------
@@ -222,8 +252,10 @@ export default class extends Controller {
222
252
  // Capture the new token from the response synchronously, so the next
223
253
  // queued request uses it without waiting for the async DOM morph.
224
254
  this.#currentToken = this.#extractToken(html) ?? this.#currentToken
225
- // Turbo applies the <turbo-stream> ops (replace/morph by id), preserving
226
- // focus/scroll/listeners on unchanged nodes.
255
+ // Turbo applies the <turbo-stream> ops by id. A plain replace is an
256
+ // outerHTML swap (focus on the replaced subtree is lost); a method="morph"
257
+ // replace (Response.morph) or an update morphs in place, preserving the
258
+ // focused input + caret on unchanged nodes — see issue #28.
227
259
  window.Turbo.renderStreamMessage(html)
228
260
  } catch (error) {
229
261
  console.error("[phlex-reactive] action error", error)
@@ -5,8 +5,10 @@ module Phlex
5
5
  # Component turns a self-contained Phlex component into a Livewire-style
6
6
  # reactive unit: declare actions in Ruby, and the generic `reactive`
7
7
  # Stimulus controller wires clicks/inputs to an HTTP round trip that
8
- # re-renders the component and morphs it back into the DOM. No per-feature
9
- # Stimulus controllers, no hand-picked Turbo targets.
8
+ # re-renders the component and applies it back into the DOM (a plain replace
9
+ # by default; return Response.morph(self) to morph in place and keep the
10
+ # focused input — issue #28). No per-feature Stimulus controllers, no
11
+ # hand-picked Turbo targets.
10
12
  #
11
13
  # Include alongside Phlex::Reactive::Streamable (which provides #id and the
12
14
  # re-render machinery).
@@ -167,6 +169,22 @@ module Phlex
167
169
  Phlex::Reactive.current_connection_id
168
170
  end
169
171
 
172
+ # Subject-bound reply builder — the preferred way to control an action's
173
+ # reply. `reply.replace.flash(:error, msg)` reads cleaner than
174
+ # `Phlex::Reactive::Response.replace(self).flash(:error, msg)`: the
175
+ # component is the implicit subject (no `self` to thread) and there's no
176
+ # constant to qualify (reply is a method, so a namespaced component needs
177
+ # no `Response = …` alias). It returns the same immutable Response the
178
+ # endpoint reads, so chaining and the legacy return-value contract are
179
+ # unchanged. See Phlex::Reactive::Reply.
180
+ #
181
+ # def archive = reply.remove
182
+ # def go_home = reply.redirect("/todos")
183
+ # def update(name:) = (@account.update!(name:); reply.morph)
184
+ def reply
185
+ Phlex::Reactive::Reply.new(self)
186
+ end
187
+
170
188
  # Root-element attributes: marks the element reactive and carries the
171
189
  # signed identity token. Spread onto the root:
172
190
  # div(id:, **reactive_attrs) { ... }
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # A component-bound facade over Response — the surface an action body uses to
6
+ # control its reply. Component#reply returns one, so an action writes
7
+ #
8
+ # reply.replace.flash(:error, msg)
9
+ #
10
+ # instead of
11
+ #
12
+ # Phlex::Reactive::Response.replace(self).flash(:error, msg)
13
+ #
14
+ # Two warts disappear: there is no constant to qualify (reply is a method,
15
+ # resolved on the component — so a namespaced component needs no
16
+ # `Response = …` alias) and no `self` to thread (the component is bound).
17
+ #
18
+ # Reply is NOT a Response and does NOT subclass one: each verb forwards to
19
+ # the Response class method, supplying the bound component as the subject, and
20
+ # returns the real, frozen Response value the endpoint already honors. So
21
+ # immutability, chaining (.flash/.stream/.also_update/.also_replace), and
22
+ # render_self? are inherited untouched — and there is no "verb after a chained
23
+ # Response" asymmetry, because the chain is plain Response all the way down.
24
+ #
25
+ # Response remains the public value object (it's what the endpoint reads); it
26
+ # is simply an internal detail you rarely name directly now.
27
+ class Reply
28
+ def initialize(component)
29
+ @component = component
30
+ end
31
+
32
+ # Self-targeting builders — the bound component is supplied implicitly.
33
+
34
+ # Re-render in place. `morph: true` morphs the subtree (preserves the
35
+ # focused input + caret) instead of an outerHTML swap — see #morph.
36
+ def replace(morph: false)
37
+ Response.replace(@component, morph:)
38
+ end
39
+
40
+ # Re-render in place via Idiomorph (method="morph") — keeps focus + caret.
41
+ def morph
42
+ Response.morph(@component)
43
+ end
44
+
45
+ # Morph only inner HTML (preserves the root element + its token attr).
46
+ def update
47
+ Response.update(@component)
48
+ end
49
+
50
+ # Remove the component's element from the DOM (render_self false).
51
+ def remove
52
+ Response.remove(@component)
53
+ end
54
+
55
+ # Subject-free builders — pass straight through so they read naturally.
56
+
57
+ # Client-side full navigation (Turbo.visit). Pass a *_url.
58
+ def redirect(url)
59
+ Response.redirect(url)
60
+ end
61
+
62
+ # Escape hatch / multi-stream root: zero or more raw turbo-stream strings.
63
+ def with(*strings)
64
+ Response.with(*strings)
65
+ end
66
+
67
+ # Self-targeting again: emit exactly these streams with a TOKEN-ONLY refresh
68
+ # (issue #30) — partial/per-field update, NO full-self replace, so the
69
+ # component's live inputs survive. The bound component supplies the token.
70
+ def streams(*strings)
71
+ Response.streams(@component, *strings)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -17,11 +17,23 @@ module Phlex
17
17
  # Response.redirect(article_url(@article)) # slug changed -> Turbo.visit the new URL
18
18
  # Response.replace(self).stream(Totals.update(@order)) # multi-stream
19
19
  class Response
20
- attr_reader :streams, :redirect_url
20
+ attr_reader :streams, :redirect_url, :token_component
21
21
 
22
22
  class << self
23
23
  # Re-render the component in place (explicit form of today's default).
24
- def replace(component) = new(streams: [component.to_stream_replace])
24
+ # `morph: true` morphs the subtree (preserves the focused input + caret)
25
+ # instead of an outerHTML swap — see .morph (issue #28).
26
+ def replace(component, morph: false)
27
+ new(streams: [morph ? component.to_stream_morph : component.to_stream_replace])
28
+ end
29
+
30
+ # Re-render the component in place via Idiomorph (issue #28). Emits
31
+ # `<turbo-stream action="replace" method="morph">`, so Turbo 8 morphs the
32
+ # subtree — the focused <input> + caret survive the save. Use this for
33
+ # per-field reactive editing (a "spreadsheet" grid where a debounced save
34
+ # fires while the user is still typing/tabbing). The morphed root still
35
+ # carries the fresh signed token, so the next action verifies.
36
+ def morph(component) = new(streams: [component.to_stream_morph])
25
37
 
26
38
  # Morph only inner HTML (preserves the root element + its token attr).
27
39
  def update(component) = new(streams: [component.to_stream_update])
@@ -39,6 +51,22 @@ module Phlex
39
51
  # Escape hatch / multi-stream root: zero or more raw turbo-stream strings.
40
52
  def with(*strings) = new(streams: strings.flatten)
41
53
 
54
+ # Partial / per-field update with a TOKEN-ONLY refresh (issue #30). Emits
55
+ # EXACTLY the given streams — no forced full-self replace — but binds
56
+ # `component` so the endpoint appends its tiny `to_stream_token` stream.
57
+ # So the signed token rolls forward (the next action verifies) while the
58
+ # component's own live inputs are never torn down: ideal for a
59
+ # spreadsheet-like grid where a debounced save re-streams only a total
60
+ # cell and the user is still typing in a sibling field.
61
+ #
62
+ # Response.streams(self, Totals.update(@invoice)) # update only the totals
63
+ #
64
+ # render_self is false (we do NOT inject the full replace); the token is
65
+ # refreshed by the bound component's token stream instead.
66
+ def streams(component, *strings)
67
+ new(streams: strings.flatten, render_self: false, token_component: component)
68
+ end
69
+
42
70
  # Build a flash turbo-stream that appends `content` into a host-app
43
71
  # container. `content` is a Phlex component instance (rendered through
44
72
  # the configured renderer so t()/url_for work) or a ready HTML string —
@@ -73,10 +101,16 @@ module Phlex
73
101
  # GUARANTEES the component's own replace is present so its
74
102
  # data-reactive-token-value refreshes (the client extracts the next token
75
103
  # from the response HTML). remove/redirect set it false (nothing stays).
76
- def initialize(streams: [], redirect_url: nil, render_self: true)
104
+ #
105
+ # token_component: set by .streams (issue #30) — a partial update that opts
106
+ # OUT of the full-self replace but still needs the token refreshed. The
107
+ # endpoint appends this component's tiny to_stream_token instead, so the
108
+ # token rolls forward without re-rendering (and clobbering) the children.
109
+ def initialize(streams: [], redirect_url: nil, render_self: true, token_component: nil)
77
110
  @streams = streams.freeze
78
111
  @redirect_url = redirect_url
79
112
  @render_self = render_self
113
+ @token_component = token_component
80
114
  freeze
81
115
  end
82
116
 
@@ -86,7 +120,8 @@ module Phlex
86
120
  self.class.new(
87
121
  streams: @streams + more.flatten,
88
122
  redirect_url: @redirect_url,
89
- render_self: @render_self
123
+ render_self: @render_self,
124
+ token_component: @token_component
90
125
  )
91
126
  end
92
127
 
@@ -113,12 +148,19 @@ module Phlex
113
148
  # Like #also_update, but renders ANOTHER Streamable component and replaces
114
149
  # it by its own #id — for a companion that is itself a component.
115
150
  # Response.replace(self).also_replace(SummaryCard.new(order: @order))
116
- def also_replace(component)
117
- stream(component.to_stream_replace)
151
+ # `morph: true` morphs the companion in place (issue #28) — use it when the
152
+ # companion also holds focusable inputs that must survive the re-render.
153
+ def also_replace(component, morph: false)
154
+ stream(morph ? component.to_stream_morph : component.to_stream_replace)
118
155
  end
119
156
 
120
157
  def redirect? = !@redirect_url.nil?
121
158
  def render_self? = @render_self
159
+
160
+ # True when a partial update (.streams) opted out of the full-self replace
161
+ # but still needs the token rolled forward — the endpoint appends the bound
162
+ # component's tiny token-only stream (issue #30).
163
+ def refresh_token? = !@token_component.nil?
122
164
  end
123
165
  end
124
166
  end
@@ -64,9 +64,13 @@ module Phlex
64
64
  renderer.render(component, layout: false)
65
65
  end
66
66
 
67
- def replace(model = nil, **options)
67
+ # `morph: true` emits `<turbo-stream action="replace" method="morph">` so
68
+ # Turbo 8's bundled Idiomorph morphs the subtree in place — preserving a
69
+ # focused <input> + caret — instead of an outerHTML swap (issue #28).
70
+ # Default (morph: false) is the unchanged plain replace.
71
+ def replace(model = nil, morph: false, **options)
68
72
  component = build(model, options)
69
- turbo_stream_builder.replace(component.id, html: render_component(component))
73
+ turbo_stream_builder.replace(component.id, html: render_component(component), **morph_method(morph))
70
74
  end
71
75
 
72
76
  def update(model = nil, **options)
@@ -103,11 +107,15 @@ module Phlex
103
107
  # connection id — pass it to suppress the actor's own echo (the actor
104
108
  # already got the action's HTTP response). With Action Cable these are
105
109
  # ignored; with pgbus they reach the dispatcher. See docs/broadcasting.
106
- def broadcast_replace_to(*streamables, model: nil, exclude: nil, visible_to: nil, **options)
110
+ # `morph: true` makes the live cross-tab update morph in place (issue #28),
111
+ # so a peer tab keeps its focus/caret on the morphed row. The broadcast
112
+ # path takes EXTRA <turbo-stream> attributes via `attributes:` (not the
113
+ # TagBuilder's `method:` kwarg), so the morph flag rides there.
114
+ def broadcast_replace_to(*streamables, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
107
115
  component = build(model, options)
108
116
  ::Turbo::StreamsChannel.broadcast_replace_to(
109
117
  *streamables, target: component.id, html: render_component(component),
110
- **broadcast_transport_opts(exclude:, visible_to:)
118
+ **morph_attributes(morph), **broadcast_transport_opts(exclude:, visible_to:)
111
119
  )
112
120
  end
113
121
 
@@ -149,6 +157,20 @@ module Phlex
149
157
  new(**(model ? component_args(model, options) : options))
150
158
  end
151
159
 
160
+ # The TagBuilder (the .replace class builder + to_stream_morph) takes
161
+ # `method: :morph` to emit the `method="morph"` attribute. Pass it ONLY
162
+ # when morphing, so the default call produces today's plain replace.
163
+ def morph_method(morph)
164
+ morph ? {method: :morph} : {}
165
+ end
166
+
167
+ # The BROADCAST path renders extra <turbo-stream> attributes through
168
+ # `attributes:` (it has no `method:` kwarg — that would fall into the
169
+ # render args and be dropped). Same wire result: method="morph".
170
+ def morph_attributes(morph)
171
+ morph ? {attributes: {method: "morph"}} : {}
172
+ end
173
+
152
174
  # Only include transport opts that were actually given, so on Action
153
175
  # Cable (which doesn't accept them) the common no-opts call is unchanged.
154
176
  def broadcast_transport_opts(exclude:, visible_to:)
@@ -184,10 +206,38 @@ module Phlex
184
206
  self.class.turbo_stream_builder.replace(id, html: self.class.render_component(self))
185
207
  end
186
208
 
209
+ # Render THIS instance as a MORPHING replace (issue #28):
210
+ # `<turbo-stream action="replace" method="morph">`. Turbo 8's bundled
211
+ # Idiomorph morphs the subtree in place — preserving the focused <input> +
212
+ # caret across the re-render — while still carrying the root's fresh
213
+ # data-reactive-token-value (so the signed token refreshes). Used by
214
+ # Response.morph / Response.replace(self, morph: true).
215
+ def to_stream_morph
216
+ self.class.turbo_stream_builder.replace(id, html: self.class.render_component(self), method: :morph)
217
+ end
218
+
187
219
  def to_stream_update
188
220
  self.class.turbo_stream_builder.update(id, html: self.class.render_component(self))
189
221
  end
190
222
 
223
+ # Render a TOKEN-ONLY refresh stream (issue #30): a tiny
224
+ # `<turbo-stream action="reactive:token">` carrying the component's fresh
225
+ # signed token, with NO rendered body. It lets an action update only PART
226
+ # of a component (its own hand-built streams) while still rolling the
227
+ # signed identity token forward — the client reads the next token from this
228
+ # attribute (#extractToken) and an inert client action writes it onto the
229
+ # root (a pure attribute set, so a focused <input> + caret survive). Unlike
230
+ # to_stream_replace, it does NOT re-render the children, so a live input
231
+ # the user is typing into is never torn down. Used by Response.streams.
232
+ #
233
+ # The component carries its token via Component#reactive_token; a Streamable
234
+ # that isn't a Component (no token) simply has nothing to refresh — guarded
235
+ # by respond_to? so the primitive stays usable on a bare Streamable.
236
+ def to_stream_token
237
+ token = respond_to?(:reactive_token) ? reactive_token : nil
238
+ %(<turbo-stream action="reactive:token" target="#{ERB::Util.html_escape(id)}" data-reactive-token-value="#{ERB::Util.html_escape(token)}"></turbo-stream>)
239
+ end
240
+
191
241
  # Render THIS instance as a remove stream. The component already knows its
192
242
  # own #id, so no record/class reconstruction is needed (works for record-
193
243
  # and state-backed components alike). Used by Response.remove.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.2.9"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.9
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -129,6 +129,7 @@ files:
129
129
  - lib/phlex/reactive.rb
130
130
  - lib/phlex/reactive/component.rb
131
131
  - lib/phlex/reactive/engine.rb
132
+ - lib/phlex/reactive/reply.rb
132
133
  - lib/phlex/reactive/response.rb
133
134
  - lib/phlex/reactive/streamable.rb
134
135
  - lib/phlex/reactive/version.rb