phlex-reactive 0.4.8 → 0.9.1

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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +924 -4
  3. data/README.md +986 -32
  4. data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
  5. data/app/javascript/phlex/reactive/compute.js +52 -7
  6. data/app/javascript/phlex/reactive/compute.min.js +4 -0
  7. data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
  8. data/app/javascript/phlex/reactive/confirm.min.js +4 -0
  9. data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
  10. data/app/javascript/phlex/reactive/reactive_controller.js +1487 -80
  11. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
  12. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
  13. data/lib/generators/phlex/reactive/component/USAGE +2 -1
  14. data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
  15. data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
  16. data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
  17. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
  18. data/lib/phlex/reactive/component/dsl.rb +249 -0
  19. data/lib/phlex/reactive/component/helpers.rb +595 -0
  20. data/lib/phlex/reactive/component/identity.rb +92 -0
  21. data/lib/phlex/reactive/component/registry.rb +200 -0
  22. data/lib/phlex/reactive/component.rb +30 -442
  23. data/lib/phlex/reactive/doctor.rb +333 -0
  24. data/lib/phlex/reactive/engine.rb +27 -9
  25. data/lib/phlex/reactive/js.rb +249 -0
  26. data/lib/phlex/reactive/log_subscriber.rb +64 -0
  27. data/lib/phlex/reactive/param_schema.rb +390 -0
  28. data/lib/phlex/reactive/reply.rb +5 -3
  29. data/lib/phlex/reactive/response.rb +160 -16
  30. data/lib/phlex/reactive/stream.rb +98 -0
  31. data/lib/phlex/reactive/streamable.rb +307 -43
  32. data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
  33. data/lib/phlex/reactive/test_helpers.rb +308 -0
  34. data/lib/phlex/reactive/version.rb +1 -1
  35. data/lib/phlex/reactive.rb +333 -14
  36. data/lib/tasks/phlex_reactive.rake +14 -0
  37. metadata +19 -1
@@ -33,6 +33,10 @@ module Phlex
33
33
  # up without ever sharing a mutable context across threads.
34
34
  ThreadViewContext = Struct.new(:view_context, :builder, :renderer, :generation)
35
35
 
36
+ # Focus ops are actor-only (they steal focus): broadcast_js_to rejects a
37
+ # broadcast that carries one. Names mirror Phlex::Reactive::JS's focus verbs.
38
+ BROADCAST_REFUSED_OPS = %w[focus focus_first].freeze
39
+
36
40
  # Every class that includes Streamable, so the engine can flush their
37
41
  # memoized view contexts on a Rails code reload (dev) in one pass. A
38
42
  # WeakMap used as a set (the class is the key) so a class reloaded by
@@ -55,6 +59,20 @@ module Phlex
55
59
  def register(klass)
56
60
  @registry_mutex.synchronize { @registry[klass] = true }
57
61
  end
62
+
63
+ # A point-in-time snapshot of every registered streamable class, taken
64
+ # under the registry lock (the doctor iterates it — issue #106). Returns
65
+ # a plain Array so callers never hold the live WeakMap or the lock while
66
+ # working; a class GC'd later simply won't appear next time. Call
67
+ # Rails.application.eager_load! FIRST if you need every app class loaded —
68
+ # the WeakMap only holds classes that have actually been loaded.
69
+ def registered_classes
70
+ @registry_mutex.synchronize do
71
+ classes = []
72
+ @registry.each_key { classes << it }
73
+ classes
74
+ end
75
+ end
58
76
  end
59
77
 
60
78
  included do
@@ -133,36 +151,71 @@ module Phlex
133
151
  # view context still carries the full Rails helper set, so
134
152
  # dom_id/url_for/t()/csrf keep working during a re-render or broadcast.
135
153
  def render_component(component)
136
- component.render_in(turbo_view_context)
154
+ # render.phlex_reactive (issue #107): the pure render cost — the unit
155
+ # an APM most wants (broadcast fan-out renders once per stream key).
156
+ # Payload carries the component NAME + html bytesize ONLY. bytesize is
157
+ # known only after rendering, so we mutate the payload inside the block.
158
+ event = { component: name, bytesize: 0 }
159
+ Phlex::Reactive.instrument("render", event) do
160
+ html = component.render_in(turbo_view_context)
161
+ event[:bytesize] = html.bytesize
162
+ html
163
+ end
137
164
  end
138
165
 
139
166
  # `morph: true` emits `<turbo-stream action="replace" method="morph">` so
140
167
  # Turbo 8's bundled Idiomorph morphs the subtree in place — preserving a
141
168
  # focused <input> + caret — instead of an outerHTML swap (issue #28).
142
169
  # Default (morph: false) is the unchanged plain replace.
170
+ # Wrapped in a Phlex::Reactive::Stream (issue #114) so the endpoint reads
171
+ # action/target/token-ness structurally. renders_root: true — a replace
172
+ # re-renders the component's own root (carrying its fresh token); wire
173
+ # bytes are byte-identical.
143
174
  def replace(model = nil, morph: false, **options)
144
175
  component = build(model, options)
145
- turbo_stream_builder.replace(component.id, html: render_component(component), **morph_method(morph))
176
+ Phlex::Reactive::Stream.wrap(
177
+ turbo_stream_builder.replace(component.id, html: render_component(component), **morph_method(morph)),
178
+ action: "replace", target: component.id, renders_root: true
179
+ )
146
180
  end
147
181
 
148
- def update(model = nil, **options)
182
+ # `morph: true` emits `<turbo-stream action="update" method="morph">` so
183
+ # Turbo 8 morphs the inner HTML in place instead of replacing it (issue
184
+ # #113) — a cross-tab update no longer clobbers a peer's focus/caret.
185
+ # Default (morph: false) is the unchanged plain update.
186
+ def update(model = nil, morph: false, **options)
149
187
  component = build(model, options)
150
- turbo_stream_builder.update(component.id, html: render_component(component))
188
+ Phlex::Reactive::Stream.wrap(
189
+ turbo_stream_builder.update(component.id, html: render_component(component), **morph_method(morph)),
190
+ action: "update", target: component.id, renders_root: true
191
+ )
151
192
  end
152
193
 
194
+ # renders_root: false — append inserts a CHILD into `target`; even when
195
+ # the appended component carries its OWN token, that must never count as
196
+ # the container's own refresh (issue #44).
153
197
  def append(target:, model: nil, **options)
154
198
  component = build(model, options)
155
- turbo_stream_builder.append(target, html: render_component(component))
199
+ Phlex::Reactive::Stream.wrap(
200
+ turbo_stream_builder.append(target, html: render_component(component)),
201
+ action: "append", target: target, renders_root: false
202
+ )
156
203
  end
157
204
 
158
205
  def prepend(target:, model: nil, **options)
159
206
  component = build(model, options)
160
- turbo_stream_builder.prepend(target, html: render_component(component))
207
+ Phlex::Reactive::Stream.wrap(
208
+ turbo_stream_builder.prepend(target, html: render_component(component)),
209
+ action: "prepend", target: target, renders_root: false
210
+ )
161
211
  end
162
212
 
163
213
  def remove(model = nil, **options)
164
214
  component = build(model, options)
165
- turbo_stream_builder.remove(component.id)
215
+ Phlex::Reactive::Stream.wrap(
216
+ turbo_stream_builder.remove(component.id),
217
+ action: "remove", target: component.id, renders_root: false
218
+ )
166
219
  end
167
220
 
168
221
  # --- Broadcasts (server -> client over the stream transport) ---
@@ -183,48 +236,211 @@ module Phlex
183
236
  # so a peer tab keeps its focus/caret on the morphed row. The broadcast
184
237
  # path takes EXTRA <turbo-stream> attributes via `attributes:` (not the
185
238
  # TagBuilder's `method:` kwarg), so the morph flag rides there.
239
+ # Each broadcast_*_to wraps its body in a broadcast.phlex_reactive event
240
+ # (issue #107) via instrument_broadcast — the stream action + streamables
241
+ # COUNT + component name, never the model/state. The event fires on BOTH
242
+ # transports (Action Cable AND pgbus): it wraps this class-method body,
243
+ # which is the same on either, so pgbus optionality is preserved.
186
244
  def broadcast_replace_to(*streamables, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
187
- component = build(model, options)
188
- ::Turbo::StreamsChannel.broadcast_replace_to(
189
- *streamables, target: component.id, html: render_component(component),
190
- **morph_attributes(morph), **broadcast_transport_opts(exclude:, visible_to:)
191
- )
245
+ instrument_broadcast("replace", streamables) do
246
+ component = build(model, options)
247
+ ::Turbo::StreamsChannel.broadcast_replace_to(
248
+ *streamables, target: component.id, html: render_component(component),
249
+ **morph_attributes(morph), **broadcast_transport_opts(exclude:, visible_to:)
250
+ )
251
+ end
192
252
  end
193
253
 
194
- def broadcast_update_to(*streamables, model: nil, exclude: nil, visible_to: nil, **options)
195
- component = build(model, options)
196
- ::Turbo::StreamsChannel.broadcast_update_to(
197
- *streamables, target: component.id, html: render_component(component),
198
- **broadcast_transport_opts(exclude:, visible_to:)
199
- )
254
+ # `morph: true` makes the live cross-tab update morph in place (issue
255
+ # #113), so a peer tab editing this component keeps its focus/caret
256
+ # instead of an inner-HTML clobber. Like broadcast_replace_to's morph
257
+ # flag, it rides through `attributes:` (the broadcast path has no
258
+ # `method:` kwarg) via morph_attributes.
259
+ def broadcast_update_to(*streamables, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
260
+ instrument_broadcast("update", streamables) do
261
+ component = build(model, options)
262
+ ::Turbo::StreamsChannel.broadcast_update_to(
263
+ *streamables, target: component.id, html: render_component(component),
264
+ **morph_attributes(morph), **broadcast_transport_opts(exclude:, visible_to:)
265
+ )
266
+ end
200
267
  end
201
268
 
202
269
  def broadcast_append_to(*streamables, target:, model: nil, exclude: nil, visible_to: nil, **options)
203
- component = build(model, options)
204
- ::Turbo::StreamsChannel.broadcast_append_to(
205
- *streamables, target:, html: render_component(component),
206
- **broadcast_transport_opts(exclude:, visible_to:)
207
- )
270
+ instrument_broadcast("append", streamables) do
271
+ component = build(model, options)
272
+ ::Turbo::StreamsChannel.broadcast_append_to(
273
+ *streamables, target:, html: render_component(component),
274
+ **broadcast_transport_opts(exclude:, visible_to:)
275
+ )
276
+ end
208
277
  end
209
278
 
210
279
  def broadcast_prepend_to(*streamables, target:, model: nil, exclude: nil, visible_to: nil, **options)
211
- component = build(model, options)
212
- ::Turbo::StreamsChannel.broadcast_prepend_to(
213
- *streamables, target:, html: render_component(component),
214
- **broadcast_transport_opts(exclude:, visible_to:)
215
- )
280
+ instrument_broadcast("prepend", streamables) do
281
+ component = build(model, options)
282
+ ::Turbo::StreamsChannel.broadcast_prepend_to(
283
+ *streamables, target:, html: render_component(component),
284
+ **broadcast_transport_opts(exclude:, visible_to:)
285
+ )
286
+ end
216
287
  end
217
288
 
218
289
  def broadcast_remove_to(*streamables, model: nil, exclude: nil, visible_to: nil, **options)
219
- component = build(model, options)
220
- ::Turbo::StreamsChannel.broadcast_remove_to(
221
- *streamables, target: component.id,
222
- **broadcast_transport_opts(exclude:, visible_to:)
223
- )
290
+ instrument_broadcast("remove", streamables) do
291
+ component = build(model, options)
292
+ ::Turbo::StreamsChannel.broadcast_remove_to(
293
+ *streamables, target: component.id,
294
+ **broadcast_transport_opts(exclude:, visible_to:)
295
+ )
296
+ end
297
+ end
298
+
299
+ # Push server-side client DOM ops to EVERY subscriber of a stream (issue
300
+ # #97) — the broadcast sibling of Response#js. Rides a `reactive:js`
301
+ # custom stream action over Turbo::StreamsChannel, so it works on Action
302
+ # Cable AND pgbus (transport opts pass through broadcast_transport_opts
303
+ # exactly like every other broadcast):
304
+ #
305
+ # Notifications::Badge.broadcast_js_to(user, :alerts,
306
+ # js.add_class("#bell", "has-unread"), exclude: reactive_connection_id)
307
+ #
308
+ # `ops` is a Phlex::Reactive::JS chain (or a raw [[op, args], ...] array);
309
+ # `target` (an element id) scopes op resolution on the client (nil →
310
+ # document-scoped). The ops JSON is HTML-escaped through the TagBuilder's
311
+ # attributes: path (data-reactive-ops), so a value can't break out of the
312
+ # attribute.
313
+ #
314
+ # The builder REJECTS focus-class ops (focus/focus_first) outright:
315
+ # broadcasting a focus steals focus in every subscriber's tab. Focus is an
316
+ # actor-reply concern only (Response#js), so it raises ArgumentError here
317
+ # rather than silently shipping a hostile-feeling broadcast.
318
+ def broadcast_js_to(*streamables, ops, exclude: nil, visible_to: nil, target: nil)
319
+ instrument_broadcast("reactive:js", streamables) do
320
+ json = broadcast_js_ops_json(ops)
321
+ ::Turbo::StreamsChannel.broadcast_action_to(
322
+ *streamables,
323
+ action: "reactive:js",
324
+ target: target,
325
+ attributes: { data: { reactive_ops: json } },
326
+ render: false,
327
+ **broadcast_transport_opts(exclude:, visible_to:)
328
+ )
329
+ end
330
+ end
331
+
332
+ # --- Multi-key fan-out (issue #119) ---------------------------------
333
+ # Broadcast ONE component to K DIFFERENT stream keys — a per-tenant loop
334
+ # ("the list page stream AND the dashboard stream"). The classic
335
+ # broadcast_*_to concatenates *streamables into ONE key, so fanning out
336
+ # to K keys the hand way is K× build + K× render + K× identity HMAC for
337
+ # BYTE-IDENTICAL HTML. These render the component ONCE and loop only the
338
+ # cheap channel call:
339
+ #
340
+ # Counter.broadcast_replace_to_each(
341
+ # accounts.map { [it, :counters] }, model: counter, exclude: reactive_connection_id)
342
+ #
343
+ # `stream_keys` is an enumerable of keys; each key is passed to the
344
+ # transport as its raw parts (a [record, :symbol] pair, or a bare string
345
+ # — `Array(key)` handles both). Transport opts (exclude:/visible_to:) and
346
+ # morph: forward PER key exactly as the single-key verbs do, so
347
+ # pgbus-present suppresses the actor's echo on every stream and
348
+ # pgbus-absent is unchanged (the no-opts call passes no unknown keyword).
349
+ #
350
+ # Irreducible exception: per-VIEWER content (visible_to: rendering
351
+ # DIFFERENT HTML per viewer) still renders per call — that's a
352
+ # render-per-viewer by definition and can't be shared. This fan-out is
353
+ # for the same payload to many keys.
354
+ def broadcast_replace_to_each(stream_keys, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
355
+ instrument_broadcast("replace", stream_keys) do
356
+ component = build(model, options)
357
+ html = render_component(component)
358
+ transport = broadcast_transport_opts(exclude:, visible_to:)
359
+ stream_keys.each do
360
+ ::Turbo::StreamsChannel.broadcast_replace_to(
361
+ *Array(it), target: component.id, html:, **morph_attributes(morph), **transport
362
+ )
363
+ end
364
+ end
365
+ end
366
+
367
+ def broadcast_update_to_each(stream_keys, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
368
+ instrument_broadcast("update", stream_keys) do
369
+ component = build(model, options)
370
+ html = render_component(component)
371
+ transport = broadcast_transport_opts(exclude:, visible_to:)
372
+ stream_keys.each do
373
+ ::Turbo::StreamsChannel.broadcast_update_to(
374
+ *Array(it), target: component.id, html:, **morph_attributes(morph), **transport
375
+ )
376
+ end
377
+ end
378
+ end
379
+
380
+ def broadcast_append_to_each(stream_keys, target:, model: nil, exclude: nil, visible_to: nil, **options)
381
+ instrument_broadcast("append", stream_keys) do
382
+ component = build(model, options)
383
+ html = render_component(component)
384
+ transport = broadcast_transport_opts(exclude:, visible_to:)
385
+ stream_keys.each do
386
+ ::Turbo::StreamsChannel.broadcast_append_to(*Array(it), target:, html:, **transport)
387
+ end
388
+ end
389
+ end
390
+
391
+ def broadcast_prepend_to_each(stream_keys, target:, model: nil, exclude: nil, visible_to: nil, **options)
392
+ instrument_broadcast("prepend", stream_keys) do
393
+ component = build(model, options)
394
+ html = render_component(component)
395
+ transport = broadcast_transport_opts(exclude:, visible_to:)
396
+ stream_keys.each do
397
+ ::Turbo::StreamsChannel.broadcast_prepend_to(*Array(it), target:, html:, **transport)
398
+ end
399
+ end
400
+ end
401
+
402
+ # remove has no body — nothing to render. It still builds ONCE to read
403
+ # the component's #id (the target), then loops the cheap channel call.
404
+ def broadcast_remove_to_each(stream_keys, model: nil, exclude: nil, visible_to: nil, **options)
405
+ instrument_broadcast("remove", stream_keys) do
406
+ component = build(model, options)
407
+ transport = broadcast_transport_opts(exclude:, visible_to:)
408
+ stream_keys.each do
409
+ ::Turbo::StreamsChannel.broadcast_remove_to(*Array(it), target: component.id, **transport)
410
+ end
411
+ end
224
412
  end
225
413
 
226
414
  private
227
415
 
416
+ # Wrap a broadcast_*_to body in a broadcast.phlex_reactive event (issue
417
+ # #107). Payload: the component NAME, the Turbo stream action, and the
418
+ # streamables COUNT — never the model/state. Fires on both transports
419
+ # because it wraps the shared class-method body. Returns the block's value
420
+ # (the broadcast result) so callers are unaffected.
421
+ def instrument_broadcast(stream_action, streamables, &)
422
+ Phlex::Reactive.instrument(
423
+ "broadcast",
424
+ { component: name, stream_action: stream_action, streamables: streamables.size },
425
+ &
426
+ )
427
+ end
428
+
429
+ # Validate + serialize broadcast ops: reject focus-class ops (they steal
430
+ # focus in every tab) and an empty chain (a dead broadcast), then return
431
+ # the JSON wire form. Works on a JS chain (inspect .ops) and a raw array.
432
+ def broadcast_js_ops_json(ops)
433
+ pairs = ops.is_a?(Phlex::Reactive::JS) ? ops.ops : Array(ops)
434
+ refused = pairs.map { |name, _| name.to_s } & Phlex::Reactive::Streamable::BROADCAST_REFUSED_OPS
435
+ unless refused.empty?
436
+ raise ArgumentError,
437
+ "broadcast_js_to refuses focus op(s) #{refused.join(", ")} — broadcasting focus " \
438
+ "steals it in every subscriber's tab. Focus is an actor-reply concern (reply.js)."
439
+ end
440
+
441
+ Phlex::Reactive::Response.js_ops_json(ops)
442
+ end
443
+
228
444
  def build(model, options)
229
445
  new(**(model ? component_args(model, options) : options))
230
446
  end
@@ -276,10 +492,32 @@ module Phlex
276
492
  end
277
493
  end
278
494
 
279
- # Required: the stable DOM id used as the Turbo Stream target. It MUST
280
- # match the id set on the component's root element in `view_template`.
495
+ # The stable DOM id used as the Turbo Stream target. It MUST match the id
496
+ # set on the component's root element in `view_template`.
497
+ #
498
+ # Record-backed components (Component's `reactive_record :x`) get a
499
+ # default for free: dom_id(record) — the id virtually every such
500
+ # component wrote by hand (issue #81). An explicit `def id` always wins
501
+ # via normal method lookup. Everything else (state-backed, a bare
502
+ # Streamable) still raises: a class-name default would silently collide
503
+ # the moment two instances render on one page. Two DIFFERENT component
504
+ # classes rendering the SAME record on one page also collide on the
505
+ # default — give one a prefixed id: `def id = dom_id(@todo, "rich")`.
506
+ #
507
+ # Called on every render/stream build, so the default stays lean: a
508
+ # respond_to? (reactive_record_key is Component API — a bare Streamable
509
+ # keeps raising) plus ivar reads via the memoized reactive_record_ivar.
281
510
  def id
282
- raise NotImplementedError, "#{self.class} must implement #id for Turbo Stream targeting"
511
+ klass = self.class
512
+ if klass.respond_to?(:reactive_record_key) && (record_ivar = klass.reactive_record_ivar) &&
513
+ (record = instance_variable_get(record_ivar))
514
+ return dom_id(record)
515
+ end
516
+
517
+ raise NotImplementedError,
518
+ "#{klass} must implement #id (the stable DOM id Turbo Streams target) — add e.g. " \
519
+ "`def id = \"my-thing\"`. Record-backed components (reactive_record :x) get " \
520
+ "`dom_id(record)` as the default automatically."
283
521
  end
284
522
 
285
523
  # Render-context-free dom_id, safe to use inside `#id`. The streamable
@@ -292,9 +530,15 @@ module Phlex
292
530
  end
293
531
 
294
532
  # Render THIS already-built instance as a replace stream (used by the
295
- # reactive action endpoint after an action mutated state).
533
+ # reactive action endpoint after an action mutated state). Wrapped in a
534
+ # Phlex::Reactive::Stream (issue #114) so the endpoint reads the action /
535
+ # target / token-ness structurally instead of regexing the markup; the wire
536
+ # bytes are byte-identical.
296
537
  def to_stream_replace
297
- self.class.turbo_stream_builder.replace(id, html: self.class.render_component(self))
538
+ Phlex::Reactive::Stream.wrap(
539
+ self.class.turbo_stream_builder.replace(id, html: self.class.render_component(self)),
540
+ action: "replace", target: id, renders_root: true
541
+ )
298
542
  end
299
543
 
300
544
  # Render THIS instance as a MORPHING replace (issue #28):
@@ -304,11 +548,23 @@ module Phlex
304
548
  # data-reactive-token-value (so the signed token refreshes). Used by
305
549
  # Response.morph / Response.replace(self, morph: true).
306
550
  def to_stream_morph
307
- self.class.turbo_stream_builder.replace(id, html: self.class.render_component(self), method: :morph)
551
+ Phlex::Reactive::Stream.wrap(
552
+ self.class.turbo_stream_builder.replace(id, html: self.class.render_component(self), method: :morph),
553
+ action: "replace", target: id, renders_root: true
554
+ )
308
555
  end
309
556
 
310
- def to_stream_update
311
- self.class.turbo_stream_builder.update(id, html: self.class.render_component(self))
557
+ # `morph: true` emits `<turbo-stream action="update" method="morph">`
558
+ # (issue #113) so Turbo 8 morphs the inner HTML in place, preserving a
559
+ # focused <input> + caret across a per-field update. Default (morph:
560
+ # false) is the unchanged plain update. Passing `method: :morph` inline
561
+ # (as #to_stream_morph does) keeps the plain call's wire byte-identical.
562
+ # Used by Response.update.
563
+ def to_stream_update(morph: false)
564
+ builder = self.class.turbo_stream_builder
565
+ html = self.class.render_component(self)
566
+ rendered = morph ? builder.update(id, html:, method: :morph) : builder.update(id, html:)
567
+ Phlex::Reactive::Stream.wrap(rendered, action: "update", target: id, renders_root: true)
312
568
  end
313
569
 
314
570
  # Render a TOKEN-ONLY refresh stream (issue #30): a tiny
@@ -334,14 +590,22 @@ module Phlex
334
590
  # has no reactive_token method at all, so it still returns false correctly.
335
591
  def to_stream_token
336
592
  token = respond_to?(:reactive_token, true) ? reactive_token : nil
337
- %(<turbo-stream action="reactive:token" target="#{ERB::Util.html_escape(id)}" data-reactive-token-value="#{ERB::Util.html_escape(token)}"></turbo-stream>)
593
+ html = %(<turbo-stream action="reactive:token" target="#{ERB::Util.html_escape(id)}" data-reactive-token-value="#{ERB::Util.html_escape(token)}"></turbo-stream>)
594
+ # Both interpolations are ERB::Util.html_escape'd, so .html_safe is a
595
+ # byte-identical relabel. renders_root: true — reactive:token IS a
596
+ # self-render for token purposes (it's in SELF_RENDER_ACTIONS) — unifies
597
+ # the actor's own token stream onto the structural path (issue #114).
598
+ Phlex::Reactive::Stream.wrap(html.html_safe, action: "reactive:token", target: id, renders_root: true)
338
599
  end
339
600
 
340
601
  # Render THIS instance as a remove stream. The component already knows its
341
602
  # own #id, so no record/class reconstruction is needed (works for record-
342
603
  # and state-backed components alike). Used by Response.remove.
343
604
  def to_stream_remove
344
- self.class.turbo_stream_builder.remove(id)
605
+ Phlex::Reactive::Stream.wrap(
606
+ self.class.turbo_stream_builder.remove(id),
607
+ action: "remove", target: id, renders_root: false
608
+ )
345
609
  end
346
610
  end
347
611
  end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RSpec matchers for reactive action Results (issue #110). Loaded lazily by
4
+ # Phlex::Reactive::TestHelpers only when RSpec is present, so the gem never hard
5
+ # depends on RSpec — a Minitest app mixes in TestHelpers and simply asserts on
6
+ # Result predicates (result.replace?, result.streams) directly.
7
+ #
8
+ # expect(run_reactive(component, :toggle)).to have_reactive_replace(component)
9
+ # expect(run_reactive(component, :archive)).to have_reactive_remove(component)
10
+ # expect(run_reactive(component, :increment)).to have_reactive_token_for(component)
11
+ #
12
+ # Each matcher accepts a component INSTANCE or a bare DOM id String, and prints a
13
+ # diff worth reading on failure (the stream actions/targets it actually saw).
14
+ #
15
+ # Style/ItBlockParameter is disabled FILE-WIDE on purpose: in RSpec::Matchers
16
+ # .define, the `do |arg|` param is the EXPECTED value and `match do |actual|` the
17
+ # MATCHED value — two distinct objects. Collapsing either to the implicit `it`
18
+ # (as the cop's autocorrect does) conflates them and silently guts the matcher.
19
+ # rubocop:disable Style/ItBlockParameter
20
+ module Phlex
21
+ module Reactive
22
+ module TestHelpers
23
+ # The DOM id a matcher argument targets: a String is the id verbatim; a
24
+ # component instance answers #id.
25
+ def self.matcher_target_id(component_or_id)
26
+ component_or_id.is_a?(::String) ? component_or_id : component_or_id.id
27
+ end
28
+
29
+ # A compact listing of the turbo-stream action="..." target="..." pairs in
30
+ # `streams` — the readable core of every failure message.
31
+ def self.describe_streams(streams)
32
+ pairs = streams.map do |stream|
33
+ open_tag = stream[/<turbo-stream\b[^>]*>/] || stream
34
+ action = open_tag[/\baction="([^"]+)"/, 1] || "?"
35
+ target = open_tag[/\btarget="([^"]+)"/, 1]
36
+ target ? "#{action}->##{target}" : action
37
+ end
38
+ pairs.empty? ? "(no streams)" : pairs.join(", ")
39
+ end
40
+
41
+ # True when a turbo-stream's opening tag re-renders `id` with one of
42
+ # `actions` at that target. Shared by the replace/remove target checks.
43
+ def self.stream_action_targets?(stream, actions, id)
44
+ open_tag = stream[/<turbo-stream\b[^>]*>/] || ""
45
+ actions.include?(open_tag[/\baction="([^"]+)"/, 1]) &&
46
+ open_tag.include?(%(target="#{ERB::Util.html_escape(id)}"))
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ RSpec::Matchers.define :have_reactive_replace do |component_or_id|
53
+ match do |result|
54
+ @id = Phlex::Reactive::TestHelpers.matcher_target_id(component_or_id)
55
+ @streams = result.streams
56
+ result.replace? &&
57
+ @streams.any? { |stream| Phlex::Reactive::TestHelpers.stream_action_targets?(stream, %w[replace update], @id) }
58
+ end
59
+
60
+ failure_message do
61
+ "expected the reactive Result to replace ##{@id}, " \
62
+ "but it did not (streams: #{Phlex::Reactive::TestHelpers.describe_streams(@streams)})"
63
+ end
64
+
65
+ failure_message_when_negated do
66
+ "expected the reactive Result NOT to replace ##{@id}, but it did " \
67
+ "(streams: #{Phlex::Reactive::TestHelpers.describe_streams(@streams)})"
68
+ end
69
+ end
70
+
71
+ RSpec::Matchers.define :have_reactive_remove do |component_or_id|
72
+ match do |result|
73
+ @id = Phlex::Reactive::TestHelpers.matcher_target_id(component_or_id)
74
+ @streams = result.streams
75
+ result.remove? &&
76
+ @streams.any? { |stream| Phlex::Reactive::TestHelpers.stream_action_targets?(stream, %w[remove], @id) }
77
+ end
78
+
79
+ failure_message do
80
+ "expected the reactive Result to remove ##{@id}, " \
81
+ "but it did not (streams: #{Phlex::Reactive::TestHelpers.describe_streams(@streams)})"
82
+ end
83
+
84
+ failure_message_when_negated do
85
+ "expected the reactive Result NOT to remove ##{@id}, but it did " \
86
+ "(streams: #{Phlex::Reactive::TestHelpers.describe_streams(@streams)})"
87
+ end
88
+ end
89
+
90
+ # Pins the #44/#46 token-refresh regression class: a reply that fails to roll the
91
+ # component's signed token forward makes the next action reject silently. This
92
+ # matcher asserts a fresh data-reactive-token-value for the component IS present.
93
+ RSpec::Matchers.define :have_reactive_token_for do |component_or_id|
94
+ match do |result|
95
+ @id = Phlex::Reactive::TestHelpers.matcher_target_id(component_or_id)
96
+ @streams = result.streams
97
+ target = %(target="#{ERB::Util.html_escape(@id)}")
98
+ @streams.any? { |stream| stream.include?("data-reactive-token-value") && stream.include?(target) }
99
+ end
100
+
101
+ failure_message do
102
+ "expected the reactive Result to carry a fresh signed token for ##{@id} " \
103
+ "(a data-reactive-token-value at its target), but none was present — the next action " \
104
+ "from ##{@id} would be rejected with a stale token (streams: " \
105
+ "#{Phlex::Reactive::TestHelpers.describe_streams(@streams)})"
106
+ end
107
+
108
+ failure_message_when_negated do
109
+ "expected the reactive Result NOT to carry a fresh token for ##{@id}, but it did"
110
+ end
111
+ end
112
+ # rubocop:enable Style/ItBlockParameter