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.
@@ -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).
@@ -60,12 +62,31 @@ module Phlex
60
62
  # A declared, client-invokable action and its param schema.
61
63
  Action = Data.define(:name, :params)
62
64
 
65
+ # A declared add/remove-row collection (issue #35): the list contract tied
66
+ # into one unit — the per-row item component, the container DOM id rows
67
+ # live in, an optional companion count id, an optional empty-state
68
+ # component, and a size resolver (a proc evaluated against the container
69
+ # instance) used to re-render the count and toggle the empty-state at the
70
+ # 0<->1 boundary. count/empty/size are nil when not declared, and the
71
+ # corresponding stream is simply omitted — so a list with just rows still
72
+ # works.
73
+ CollectionDefinition = Data.define(:name, :item, :container, :count, :empty, :size) do
74
+ # The collection's current size, evaluated against the bound container
75
+ # instance (instance_exec so the proc reads the component's ivars /
76
+ # association). nil when no resolver was declared — callers skip the
77
+ # count/empty streams that need a number.
78
+ def size_for(component)
79
+ size && component.instance_exec(&size)
80
+ end
81
+ end
82
+
63
83
  class_methods do
64
84
  # Declare the ActiveRecord (GlobalID-able) record this component is
65
85
  # rebuilt from. The signed token carries its GlobalID; the server
66
86
  # re-finds it on each action. State lives in the DB.
67
87
  def reactive_record(name)
68
88
  @reactive_record_key = name.to_sym
89
+ remove_instance_variable(:@reactive_record_ivar) if defined?(@reactive_record_ivar)
69
90
  end
70
91
 
71
92
  def reactive_record_key
@@ -78,6 +99,7 @@ module Phlex
78
99
  # reactive_state :count, :open
79
100
  def reactive_state(*names)
80
101
  reactive_state_keys.concat(names.map(&:to_sym))
102
+ @reactive_state_ivars = nil # rebuild the cached [key, ivar] pairs
81
103
  end
82
104
 
83
105
  def reactive_state_keys
@@ -118,6 +140,59 @@ module Phlex
118
140
  reactive_actions.key?(name.to_sym)
119
141
  end
120
142
 
143
+ # Declare an add/remove-row collection (issue #35) — the list contract
144
+ # in one place, so actions append/prepend/remove a row WITHOUT
145
+ # re-deriving the container id, count, and empty-state in every action:
146
+ #
147
+ # reactive_collection :items,
148
+ # item: ItemRowComponent, # the per-row Streamable component
149
+ # container: "items-list", # the DOM id rows live in
150
+ # count: "items-count", # optional companion id (the size badge)
151
+ # empty: ItemsEmptyComponent, # optional empty-state component
152
+ # size: -> { @record.items.size } # resolves the live size
153
+ #
154
+ # An action then governs the reply with one call:
155
+ # reply.append(:items, item) # row append + count + clear empty-state
156
+ # reply.remove(:items, id) # row remove + count + restore empty-state
157
+ #
158
+ # count/empty/size are optional: a list with just rows omits them and the
159
+ # corresponding stream isn't emitted. See Phlex::Reactive::Reply and the
160
+ # README "Reactive collections" section.
161
+ def reactive_collection(name, item:, container:, count: nil, empty: nil, size: nil)
162
+ reactive_collections[name.to_sym] =
163
+ CollectionDefinition.new(name: name.to_sym, item:, container:, count:, empty:, size:)
164
+ end
165
+
166
+ def reactive_collections
167
+ @reactive_collections ||= superclass.respond_to?(:reactive_collections) ? superclass.reactive_collections.dup : {}
168
+ end
169
+
170
+ def reactive_collection_def(name)
171
+ reactive_collections[name.to_sym]
172
+ end
173
+
174
+ def reactive_collection?(name)
175
+ reactive_collections.key?(name.to_sym)
176
+ end
177
+
178
+ # The record's instance-variable symbol (e.g. :@todo), computed once.
179
+ # reactive_token reads it on every render; interpolating :"@#{key}" each
180
+ # time would allocate a symbol per render. Nil when record-less. Memoized
181
+ # per class; reset alongside reactive_record so it can't go stale.
182
+ def reactive_record_ivar
183
+ return @reactive_record_ivar if defined?(@reactive_record_ivar)
184
+
185
+ @reactive_record_ivar = reactive_record_key ? :"@#{reactive_record_key}" : nil
186
+ end
187
+
188
+ # [string_key, ivar_symbol] pairs for the signed state, computed once.
189
+ # reactive_token walks these every render; precomputing the "count"/:@count
190
+ # forms avoids a String + Symbol allocation per key per render. Memoized
191
+ # per class; reset when reactive_state adds a key.
192
+ def reactive_state_ivars
193
+ @reactive_state_ivars ||= reactive_state_keys.map { [it.to_s, :"@#{it}"] }
194
+ end
195
+
121
196
  # Rebuild a component instance from a verified identity payload. Called
122
197
  # by the action endpoint after the token signature is verified.
123
198
  #
@@ -137,14 +212,14 @@ module Phlex
137
212
 
138
213
  if reactive_state_keys.any?
139
214
  state = payload.fetch("s", {})
140
- reactive_state_keys.each do |key|
215
+ reactive_state_keys.each do
141
216
  # Use key presence, not the value: a signed `nil` (nullable state)
142
217
  # must round-trip distinctly. Only a genuinely absent key falls
143
218
  # back to the component's initialize default; `false` and `nil`
144
219
  # both survive.
145
- next unless state.key?(key.to_s)
220
+ next unless state.key?(it.to_s)
146
221
 
147
- kwargs[key] = state[key.to_s]
222
+ kwargs[it] = state[it.to_s]
148
223
  end
149
224
  end
150
225
 
@@ -167,6 +242,22 @@ module Phlex
167
242
  Phlex::Reactive.current_connection_id
168
243
  end
169
244
 
245
+ # Subject-bound reply builder — the preferred way to control an action's
246
+ # reply. `reply.replace.flash(:error, msg)` reads cleaner than
247
+ # `Phlex::Reactive::Response.replace(self).flash(:error, msg)`: the
248
+ # component is the implicit subject (no `self` to thread) and there's no
249
+ # constant to qualify (reply is a method, so a namespaced component needs
250
+ # no `Response = …` alias). It returns the same immutable Response the
251
+ # endpoint reads, so chaining and the legacy return-value contract are
252
+ # unchanged. See Phlex::Reactive::Reply.
253
+ #
254
+ # def archive = reply.remove
255
+ # def go_home = reply.redirect("/todos")
256
+ # def update(name:) = (@account.update!(name:); reply.morph)
257
+ def reply
258
+ Phlex::Reactive::Reply.new(self)
259
+ end
260
+
170
261
  # Root-element attributes: marks the element reactive and carries the
171
262
  # signed identity token. Spread onto the root:
172
263
  # div(id:, **reactive_attrs) { ... }
@@ -193,12 +284,18 @@ module Phlex
193
284
  # live-update-as-you-type doesn't POST per keystroke. A blur flushes a
194
285
  # pending dispatch so the last edit is never dropped. Omit it for the
195
286
  # immediate-dispatch default.
287
+ # The verbatim JSON for an empty explicit-params payload. The common
288
+ # trigger (on(:increment), no params) hits this on EVERY render — skipping
289
+ # params.to_json (which re-serializes {} to the same "{}" each time) avoids
290
+ # a per-render allocation while keeping the wire format byte-identical.
291
+ EMPTY_PARAMS_JSON = "{}"
292
+
196
293
  def on(action_name, event: "click", debounce: nil, **params)
197
294
  attrs = {
198
295
  data: {
199
296
  action: "#{event}->reactive#dispatch",
200
297
  reactive_action_param: action_name.to_s,
201
- reactive_params_param: params.to_json
298
+ reactive_params_param: params.empty? ? EMPTY_PARAMS_JSON : params.to_json
202
299
  }
203
300
  }
204
301
  attrs[:data][:reactive_debounce_param] = debounce if debounce
@@ -216,7 +313,7 @@ module Phlex
216
313
  # hatch). The trigger (on(:save)) stays on the button, not the field — so
217
314
  # focusing the input doesn't dispatch and collapse edit mode.
218
315
  def reactive_field(param, **attrs)
219
- {name: param.to_s, **attrs}
316
+ { name: param.to_s, **attrs }
220
317
  end
221
318
 
222
319
  # Render an <input> already bound to an action param (issue #23). Sugar for
@@ -230,8 +327,8 @@ module Phlex
230
327
  # is the element's content, so the awkward FormBuilder positional split
231
328
  # (where name: lands after the options/html-options args) goes away:
232
329
  # reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }
233
- def reactive_select(param, **attrs, &block)
234
- select(**reactive_field(param, **attrs), &block)
330
+ def reactive_select(param, **attrs, &)
331
+ select(**reactive_field(param, **attrs), &)
235
332
  end
236
333
 
237
334
  # Map a declared nested param onto Rails' <assoc>_attributes, carrying the
@@ -247,7 +344,7 @@ module Phlex
247
344
  existing = reactive_record_for_nested.public_send(association)
248
345
  merged[:id] = existing.id if existing
249
346
 
250
- {"#{association}_attributes": merged}
347
+ { "#{association}_attributes": merged }
251
348
  end
252
349
 
253
350
  # Map a nested param onto <assoc>_attributes (with id preservation) AND
@@ -265,9 +362,7 @@ module Phlex
265
362
  # record-backed component).
266
363
  def reactive_record_for_nested
267
364
  key = self.class.reactive_record_key
268
- unless key
269
- raise Error, "#{self.class} must declare `reactive_record` to use nested_update!/nested_attributes"
270
- end
365
+ raise Error, "#{self.class} must declare `reactive_record` to use nested_update!/nested_attributes" unless key
271
366
 
272
367
  instance_variable_get(:"@#{key}")
273
368
  end
@@ -279,17 +374,18 @@ module Phlex
279
374
  # record. Record-only ({c, gid}) and state-only ({c, s}) shapes are
280
375
  # unchanged.
281
376
  def reactive_token
282
- payload = {"c" => self.class.name}
377
+ klass = self.class
378
+ payload = { "c" => klass.name }
283
379
 
284
- if self.class.reactive_record_key
285
- record = instance_variable_get(:"@#{self.class.reactive_record_key}")
286
- payload["gid"] = record.to_gid.to_s
380
+ if (record_ivar = klass.reactive_record_ivar)
381
+ payload["gid"] = instance_variable_get(record_ivar).to_gid.to_s
287
382
  end
288
383
 
289
- if self.class.reactive_state_keys.any?
290
- payload["s"] = self.class.reactive_state_keys.to_h do |k|
291
- [k.to_s, instance_variable_get(:"@#{k}").as_json]
292
- end
384
+ state_ivars = klass.reactive_state_ivars
385
+ unless state_ivars.empty?
386
+ state = {}
387
+ state_ivars.each { |key, ivar| state[key] = instance_variable_get(ivar).as_json }
388
+ payload["s"] = state
293
389
  end
294
390
 
295
391
  Phlex::Reactive.sign(payload)
@@ -13,27 +13,27 @@ module Phlex
13
13
 
14
14
  # Mount POST /reactive/actions -> Phlex::Reactive::ActionsController#create.
15
15
  # Apps can change the path with Phlex::Reactive.action_path before boot.
16
- initializer "phlex_reactive.routes" do |app|
17
- app.routes.append do
16
+ initializer "phlex_reactive.routes" do
17
+ it.routes.append do
18
18
  post Phlex::Reactive.action_path, to: "phlex/reactive/actions#create", as: :phlex_reactive_action
19
19
  end
20
20
  end
21
21
 
22
22
  # Make reactive_controller.js available to Propshaft/Sprockets so it can
23
23
  # be included via the asset pipeline or pinned in importmap.
24
- initializer "phlex_reactive.assets" do |app|
25
- if app.config.respond_to?(:assets)
26
- app.config.assets.paths << root.join("app/javascript").to_s
27
- app.config.assets.precompile += %w[phlex/reactive/reactive_controller.js]
24
+ initializer "phlex_reactive.assets" do
25
+ if it.config.respond_to?(:assets)
26
+ it.config.assets.paths << root.join("app/javascript").to_s
27
+ it.config.assets.precompile += %w[phlex/reactive/reactive_controller.js]
28
28
  end
29
29
  end
30
30
 
31
31
  # Auto-pin the client controller for importmap apps so it loads without
32
32
  # manual configuration. Apps that don't use importmap include it via the
33
33
  # asset pipeline instead (see README).
34
- initializer "phlex_reactive.importmap", after: "importmap" do |app|
35
- if defined?(::Importmap::Map) && app.respond_to?(:importmap)
36
- app.importmap.pin(
34
+ initializer "phlex_reactive.importmap", after: "importmap" do
35
+ if defined?(::Importmap::Map) && it.respond_to?(:importmap)
36
+ it.importmap.pin(
37
37
  "phlex/reactive/reactive_controller",
38
38
  to: "phlex/reactive/reactive_controller.js",
39
39
  preload: true
@@ -41,6 +41,15 @@ module Phlex
41
41
  end
42
42
  end
43
43
 
44
+ # Flush memoized off-request view contexts on every code reload (dev) so a
45
+ # reloaded renderer controller class is never served from a stale memo. In
46
+ # production to_prepare runs once (eager load), so the cache simply builds
47
+ # fresh after boot. See Streamable.reset_all_view_contexts!.
48
+ config.to_prepare do
49
+ Phlex::Reactive::Streamable.reset_all_view_contexts!
50
+ Phlex::Reactive.reset_flash_builder!
51
+ end
52
+
44
53
  # Boot-time guard (issue #26): warn if the action path doesn't resolve to
45
54
  # the gem controller. Runs after_initialize so the host's full route set
46
55
  # (including a bottom-of-file catch-all that would shadow our appended
@@ -0,0 +1,102 @@
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
+ # Distinguishes `reply.remove` (no args — remove the bound component
29
+ # itself) from `reply.remove(:items, model)` (remove a collection row).
30
+ # A sentinel, not nil, so `reply.remove(:items, nil)` stays unambiguous.
31
+ UNSET = Object.new
32
+ private_constant :UNSET
33
+
34
+ def initialize(component)
35
+ @component = component
36
+ end
37
+
38
+ # Self-targeting builders — the bound component is supplied implicitly.
39
+
40
+ # Re-render in place. `morph: true` morphs the subtree (preserves the
41
+ # focused input + caret) instead of an outerHTML swap — see #morph.
42
+ def replace(morph: false)
43
+ Response.replace(@component, morph:)
44
+ end
45
+
46
+ # Re-render in place via Idiomorph (method="morph") — keeps focus + caret.
47
+ def morph
48
+ Response.morph(@component)
49
+ end
50
+
51
+ # Morph only inner HTML (preserves the root element + its token attr).
52
+ def update
53
+ Response.update(@component)
54
+ end
55
+
56
+ # Reactive collections (issue #35) — add/remove a row in a declared
57
+ # reactive_collection, emitting the row stream + the count companion +
58
+ # the empty-state toggle as ONE Response. The bound component is the
59
+ # container (it carries the declaration + size resolver).
60
+ #
61
+ # def add_item(...) = (item = @list.items.create!(...); reply.append(:items, item))
62
+ # def remove_item(id:) = (@list.items.find(id).destroy!; reply.remove(:items, id))
63
+ #
64
+ # `model` is the row's record; remove also accepts the row's dom-id string.
65
+ def append(name, model)
66
+ Response.collection_append(@component, name, model)
67
+ end
68
+
69
+ def prepend(name, model)
70
+ Response.collection_prepend(@component, name, model)
71
+ end
72
+
73
+ # Two forms:
74
+ # reply.remove # remove the bound component's own element
75
+ # reply.remove(:items, model) # remove a collection row + count + empty
76
+ def remove(name = UNSET, model = UNSET)
77
+ return Response.remove(@component) if name.equal?(UNSET)
78
+
79
+ Response.collection_remove(@component, name, model)
80
+ end
81
+
82
+ # Subject-free builders — pass straight through so they read naturally.
83
+
84
+ # Client-side full navigation (Turbo.visit). Pass a *_url.
85
+ def redirect(url)
86
+ Response.redirect(url)
87
+ end
88
+
89
+ # Escape hatch / multi-stream root: zero or more raw turbo-stream strings.
90
+ def with(*strings)
91
+ Response.with(*strings)
92
+ end
93
+
94
+ # Self-targeting again: emit exactly these streams with a TOKEN-ONLY refresh
95
+ # (issue #30) — partial/per-field update, NO full-self replace, so the
96
+ # component's live inputs survive. The bound component supplies the token.
97
+ def streams(*strings)
98
+ Response.streams(@component, *strings)
99
+ end
100
+ end
101
+ end
102
+ 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,120 @@ 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
+ # --- Reactive collections (issue #35) ---
55
+ # Add/remove a row in a declared reactive_collection, emitting the row
56
+ # stream + the count companion update + the empty-state toggle as ONE
57
+ # Response. `component` is the bound CONTAINER (it carries the
58
+ # declaration and the size resolver); `model` is the row's record (or, for
59
+ # remove, a model OR a dom-id string). count/empty/size are optional — a
60
+ # stream is emitted only for the pieces declared.
61
+ #
62
+ # render_self is false: the row append/prepend/remove IS the update, so
63
+ # we must NOT also replace the whole container (that would re-render every
64
+ # row and clobber the just-streamed delta).
65
+ #
66
+ # token_component is the CONTAINER (cosmos#1939): a reply that does NOT
67
+ # re-render self must STILL refresh self's signed token, or the list is
68
+ # add-once-only — correct on the first click, then every subsequent dispatch
69
+ # from the list root is rejected (its token went stale) with no error. The
70
+ # container owns the add/remove trigger, so the endpoint appends its inert
71
+ # `reactive:token` stream (the same #30 machinery reply.streams uses) to roll
72
+ # the token forward without re-rendering the rows.
73
+ def collection_append(component, name, model)
74
+ definition = collection_def!(component, name)
75
+ new(streams: collection_add_streams(definition, component, model, :append),
76
+ render_self: false, token_component: component)
77
+ end
78
+
79
+ def collection_prepend(component, name, model)
80
+ definition = collection_def!(component, name)
81
+ new(streams: collection_add_streams(definition, component, model, :prepend),
82
+ render_self: false, token_component: component)
83
+ end
84
+
85
+ def collection_remove(component, name, model)
86
+ definition = collection_def!(component, name)
87
+ new(streams: collection_remove_streams(definition, component, model),
88
+ render_self: false, token_component: component)
89
+ end
90
+
91
+ private
92
+
93
+ # Resolve the declaration off the container's class, raising a clear error
94
+ # for an undeclared name (a typo'd collection should fail loudly, not
95
+ # silently emit an empty Response).
96
+ def collection_def!(component, name)
97
+ component.class.reactive_collection_def(name) ||
98
+ raise(Phlex::Reactive::Error, "undeclared reactive_collection :#{name} on #{component.class}")
99
+ end
100
+
101
+ # Row add (append/prepend) + count + empty-state clear. The empty-state is
102
+ # removed only when the list just crossed 0->1 (size == 1) — appending to
103
+ # an already-populated list leaves it untouched.
104
+ def collection_add_streams(definition, component, model, action)
105
+ streams = [definition.item.public_send(action, target: definition.container, model:)]
106
+ append_count_stream(streams, definition, component)
107
+
108
+ size = definition.size_for(component)
109
+ streams << definition.empty.new.to_stream_remove if definition.empty && size == 1
110
+ streams
111
+ end
112
+
113
+ # Row remove + count + empty-state restore. The empty-state is appended
114
+ # back into the container only when the list just emptied (size == 0).
115
+ def collection_remove_streams(definition, component, model)
116
+ streams = [collection_row_remove(definition, model)]
117
+ append_count_stream(streams, definition, component)
118
+
119
+ size = definition.size_for(component)
120
+ if definition.empty && size&.zero?
121
+ # Render the empty-state and append it INTO the container (not its
122
+ # own id) — restoring "No items yet" when the last row was removed.
123
+ # model: nil builds it argument-free (an empty-state is a static view).
124
+ streams << definition.empty.append(target: definition.container, model: nil)
125
+ end
126
+ streams
127
+ end
128
+
129
+ # Remove the row by its DOM id. Accepts the record (so dom_id is derived)
130
+ # or an already-built dom-id string (e.g. the value the row used as #id).
131
+ def collection_row_remove(definition, model)
132
+ if model.is_a?(String)
133
+ Phlex::Reactive.flash_builder.remove(model)
134
+ else
135
+ definition.item.remove(model)
136
+ end
137
+ end
138
+
139
+ # Append the count companion's update stream when a count id + a size
140
+ # resolver are both declared. The size is a number, HTML-escaped by Turbo.
141
+ def append_count_stream(streams, definition, component)
142
+ return unless definition.count
143
+
144
+ size = definition.size_for(component)
145
+ return if size.nil?
146
+
147
+ streams << update_stream(definition.count, size.to_s)
148
+ end
149
+
150
+ public
151
+
152
+ # Partial / per-field update with a TOKEN-ONLY refresh (issue #30). Emits
153
+ # EXACTLY the given streams — no forced full-self replace — but binds
154
+ # `component` so the endpoint appends its tiny `to_stream_token` stream.
155
+ # So the signed token rolls forward (the next action verifies) while the
156
+ # component's own live inputs are never torn down: ideal for a
157
+ # spreadsheet-like grid where a debounced save re-streams only a total
158
+ # cell and the user is still typing in a sibling field.
159
+ #
160
+ # Response.streams(self, Totals.update(@invoice)) # update only the totals
161
+ #
162
+ # render_self is false (we do NOT inject the full replace); the token is
163
+ # refreshed by the bound component's token stream instead.
164
+ def streams(component, *strings)
165
+ new(streams: strings.flatten, render_self: false, token_component: component)
166
+ end
167
+
42
168
  # Build a flash turbo-stream that appends `content` into a host-app
43
169
  # container. `content` is a Phlex component instance (rendered through
44
170
  # the configured renderer so t()/url_for work) or a ready HTML string —
@@ -73,10 +199,16 @@ module Phlex
73
199
  # GUARANTEES the component's own replace is present so its
74
200
  # data-reactive-token-value refreshes (the client extracts the next token
75
201
  # from the response HTML). remove/redirect set it false (nothing stays).
76
- def initialize(streams: [], redirect_url: nil, render_self: true)
202
+ #
203
+ # token_component: set by .streams (issue #30) — a partial update that opts
204
+ # OUT of the full-self replace but still needs the token refreshed. The
205
+ # endpoint appends this component's tiny to_stream_token instead, so the
206
+ # token rolls forward without re-rendering (and clobbering) the children.
207
+ def initialize(streams: [], redirect_url: nil, render_self: true, token_component: nil)
77
208
  @streams = streams.freeze
78
209
  @redirect_url = redirect_url
79
210
  @render_self = render_self
211
+ @token_component = token_component
80
212
  freeze
81
213
  end
82
214
 
@@ -86,7 +218,8 @@ module Phlex
86
218
  self.class.new(
87
219
  streams: @streams + more.flatten,
88
220
  redirect_url: @redirect_url,
89
- render_self: @render_self
221
+ render_self: @render_self,
222
+ token_component: @token_component
90
223
  )
91
224
  end
92
225
 
@@ -113,12 +246,19 @@ module Phlex
113
246
  # Like #also_update, but renders ANOTHER Streamable component and replaces
114
247
  # it by its own #id — for a companion that is itself a component.
115
248
  # Response.replace(self).also_replace(SummaryCard.new(order: @order))
116
- def also_replace(component)
117
- stream(component.to_stream_replace)
249
+ # `morph: true` morphs the companion in place (issue #28) — use it when the
250
+ # companion also holds focusable inputs that must survive the re-render.
251
+ def also_replace(component, morph: false)
252
+ stream(morph ? component.to_stream_morph : component.to_stream_replace)
118
253
  end
119
254
 
120
255
  def redirect? = !@redirect_url.nil?
121
256
  def render_self? = @render_self
257
+
258
+ # True when a partial update (.streams) opted out of the full-self replace
259
+ # but still needs the token rolled forward — the endpoint appends the bound
260
+ # component's tiny token-only stream (issue #30).
261
+ def refresh_token? = !@token_component.nil?
122
262
  end
123
263
  end
124
264
  end