phlex-reactive 0.4.7 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +970 -0
- data/README.md +1134 -49
- data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
- data/app/javascript/phlex/reactive/compute.js +93 -0
- data/app/javascript/phlex/reactive/compute.min.js +4 -0
- data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
- data/app/javascript/phlex/reactive/confirm.min.js +4 -0
- data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +1591 -56
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
- data/lib/generators/phlex/reactive/component/USAGE +2 -1
- data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
- data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
- data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
- data/lib/phlex/reactive/component/dsl.rb +249 -0
- data/lib/phlex/reactive/component/helpers.rb +595 -0
- data/lib/phlex/reactive/component/identity.rb +92 -0
- data/lib/phlex/reactive/component/registry.rb +200 -0
- data/lib/phlex/reactive/component.rb +37 -348
- data/lib/phlex/reactive/doctor.rb +333 -0
- data/lib/phlex/reactive/engine.rb +36 -7
- data/lib/phlex/reactive/js.rb +222 -0
- data/lib/phlex/reactive/log_subscriber.rb +64 -0
- data/lib/phlex/reactive/param_schema.rb +390 -0
- data/lib/phlex/reactive/reply.rb +5 -3
- data/lib/phlex/reactive/response.rb +156 -16
- data/lib/phlex/reactive/stream.rb +98 -0
- data/lib/phlex/reactive/streamable.rb +307 -43
- data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
- data/lib/phlex/reactive/test_helpers.rb +308 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +329 -14
- data/lib/tasks/phlex_reactive.rake +14 -0
- metadata +20 -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
#
|
|
280
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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
|