phlex-reactive 0.12.3 → 0.12.5

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.
@@ -267,7 +267,7 @@ module Phlex
267
267
  assert_no_scope_double_nesting!(scope, params, name.to_sym) if scope
268
268
  Registry.write_entry(
269
269
  self, :actions, name.to_sym,
270
- Action.new(name: name.to_sym, params: params, schema: Phlex::Reactive::ParamSchema.compile(params))
270
+ ActionDefinition.new(name: name.to_sym, params: params, schema: Phlex::Reactive::ParamSchema.compile(params))
271
271
  )
272
272
  end
273
273
 
@@ -85,6 +85,10 @@ module Phlex
85
85
  # JS), so the client's attr check would never fire (the on()/warn_unsaved
86
86
  # precedent). Off by default → no key, no string, zero client surface.
87
87
  data[:reactive_debug] = "true" if Phlex::Reactive.debug
88
+ # Issue #237: the verbose gate for the client's zero-target op warning.
89
+ # ON by default in dev/test (Rails.env.local?), so the papercut warning
90
+ # works out of the box; production renders no attr and stays silent.
91
+ data[:reactive_verbose] = "true" if Phlex::Reactive.verbose_errors
88
92
  # Field-name scope (issue #180): the client prefixes bare binding field
89
93
  # names with `scope[...]`. Omitted entirely when undeclared — byte-stable
90
94
  # wire for unscoped components.
@@ -408,6 +412,11 @@ module Phlex
408
412
  # on()'s flags above.
409
413
  attrs[:data][:reactive_outside_param] = "true" if outside
410
414
  attrs[:data][:reactive_window_param] = "true" if window_bound
415
+ # Issue #228: mark a clipboard-reading trigger so the client can gate
416
+ # its visibility on availability — on connect the controller sets
417
+ # hidden = !navigator.clipboard.readText. Render the trigger `hidden`
418
+ # for reveal-when-available (a dead paste button never shows).
419
+ attrs[:data][:reactive_clipboard] = "true" if ops.ops.any? { |name, _| name == "paste_into" }
411
420
  # Issue #178: confirm: gates the client-op chain behind the SAME
412
421
  # overridable confirmResolver on(:action, confirm:) uses (#52/#55). Emits
413
422
  # the identical data-reactive-confirm-param; the client's runOps prompts
@@ -84,7 +84,14 @@ module Phlex
84
84
  # is the compiled Phlex::Reactive::ParamSchema (issue #109) the endpoint
85
85
  # coerces through — built ONCE at declaration so a typo'd type symbol
86
86
  # raises Phlex::Reactive::UnknownParamType at class load, not at click time.
87
- Action = Data.define(:name, :params, :schema)
87
+ #
88
+ # Named ActionDefinition (issue #233), NOT Action: this module sits in
89
+ # every reactive component's ancestry, so a bare `Action` constant
90
+ # shadows a host app's Phlex kit component of the same name under lazy
91
+ # autoloading (Kit's LazyLoader resolves through the calling class's
92
+ # const_get). Keep every constant here suffixed / implausible as a kit
93
+ # component name — and never alias the old name back.
94
+ ActionDefinition = Data.define(:name, :params, :schema)
88
95
 
89
96
  # A declared client-side computation (data binding). `inputs`/`outputs` are
90
97
  # the action-param names of the fields the reducer reads/writes; `reducer`
@@ -185,6 +185,35 @@ module Phlex
185
185
  append("submit", target_args(to, global:))
186
186
  end
187
187
 
188
+ # --- Clipboard-source paste (issue #228) ---
189
+ #
190
+ # paste_into(to) — on a user gesture, read navigator.clipboard.readText()
191
+ # and feed the text into the target field through the normal `input`
192
+ # pipeline: set .value, dispatch a bubbling `input` event (so
193
+ # reactive_compute reducers, reactive_show, and reactive_on_complete all
194
+ # run exactly as if the user had typed it), then focus the field. The
195
+ # permission UX is the browser's own; a rejected or unavailable read is a
196
+ # SILENT NO-OP — page state must not change. The target is a FIELD: there
197
+ # is no :root default and an explicit :root is refused loudly (pasting
198
+ # into the root div is always a call-site bug). ACTOR-ONLY like
199
+ # focus/submit: allowed from on_client / reply.js / reactive_on_complete,
200
+ # refused in broadcast_to(js:) — a broadcast that reads every
201
+ # subscriber's clipboard would be hostile. on_client marks the trigger
202
+ # with data-reactive-clipboard so the client can hide it wherever the
203
+ # clipboard API is missing (no dead button). NOTE: the gate owns the
204
+ # trigger's `hidden` flag — don't also bind reactive_show to the trigger
205
+ # element (the two passes would fight over the same attribute).
206
+
207
+ def paste_into(to, global: false)
208
+ if to == :root
209
+ raise ArgumentError,
210
+ "#{self.class}: paste_into targets a field, not the component root — " \
211
+ "pass the input's CSS selector (e.g. js.paste_into(\"[name=code]\"))"
212
+ end
213
+
214
+ append("paste_into", target_args(to, global:))
215
+ end
216
+
188
217
  # --- Text content (issue #159) ---
189
218
  #
190
219
  # text(to, value) — set the target's textContent (stringified; nil clears).
@@ -314,7 +314,11 @@ data-reactive-flash-level="#{level_attr}"#{dismiss_attr(dismiss_after)}>#{body}<
314
314
  def js_stream(ops, target: nil)
315
315
  json = js_ops_json(ops)
316
316
  target_attr = target ? %( target="#{ERB::Util.html_escape(target)}") : ""
317
- %(<turbo-stream action="reactive:js"#{target_attr} \
317
+ # Issue #237: carry the verbose gate on the stream element itself, so
318
+ # the client can warn on zero-target resolution even for a
319
+ # document-scoped op with no controller root to read the flag from.
320
+ verbose_attr = Phlex::Reactive.verbose_errors ? %( data-reactive-verbose="true") : ""
321
+ %(<turbo-stream action="reactive:js"#{target_attr}#{verbose_attr} \
318
322
  data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
319
323
  end
320
324
 
@@ -121,11 +121,10 @@ module Phlex
121
121
  end
122
122
 
123
123
  alternatives = negative.flat_map { |field, value| negative_alternatives(field, value) }
124
- # rubocop:disable Style/ItBlockParameter -- nested block: `it` would shadow `group`
124
+ # rubocop:disable-next Style/ItBlockParameter -- nested block: `it` would shadow `group`
125
125
  groups.flat_map do |group|
126
126
  alternatives.map { |extra| group + extra }
127
127
  end
128
- # rubocop:enable Style/ItBlockParameter
129
128
  end
130
129
 
131
130
  # --- the value language (positive) -------------------------------------
@@ -35,10 +35,11 @@ module Phlex
35
35
 
36
36
  # Actor-only ops: a js broadcast rejects a broadcast that carries one.
37
37
  # Focus ops steal focus in every subscriber's tab (issue #96); submit
38
- # (issue #226) would force-submit every subscriber's form. Both belong to
38
+ # (issue #226) would force-submit every subscriber's form; paste_into
39
+ # (issue #228) would read every subscriber's clipboard. They belong to
39
40
  # the actor's own reply (reply.js) or gesture (on_client / a reducer's
40
41
  # $ops), never a broadcast. Names mirror Phlex::Reactive::JS's verbs.
41
- BROADCAST_REFUSED_OPS = %w[focus focus_first submit].freeze
42
+ BROADCAST_REFUSED_OPS = %w[focus focus_first submit paste_into].freeze
42
43
 
43
44
  # The broadcast_to verb kwargs (issue #185) → their Turbo stream action.
44
45
  # SELF-TARGETING verbs derive the target from the component's #id (require a
@@ -124,22 +125,51 @@ module Phlex
124
125
  "append/prepend/remove), not client-op dispatches"
125
126
  end
126
127
  resolved_target = resolve_broadcast_target(verb, component, payload, target)
127
- # broadcast_js_ops_json is a private class method on the owner Streamable
128
- # class (reached via send); the instrumentation + pgbus thread-locals are
129
- # owned HERE so the class-level and module-level (plain-component) forms
130
- # share ONE path and neither is silently un-instrumented (issue #185).
128
+ # The instrumentation + pgbus thread-locals are owned HERE so the
129
+ # class-level and module-level (plain-component) forms share ONE path
130
+ # and neither is silently un-instrumented (issue #185). The js ops
131
+ # serializer lives HERE too (issue #228): when it was a private method
132
+ # on the includer class, the module-level owner (the Streamable module
133
+ # itself) crashed with NoMethodError instead of refusing — the
134
+ # actor-only gate must be reachable from BOTH broadcast doors.
131
135
  component_name = component ? component.class.name : owner.name
132
136
  Phlex::Reactive.instrument(
133
137
  "broadcast", { component: component_name, stream_action: BROADCAST_VERBS[verb], streamables: keys.size }
134
138
  ) do
135
139
  with_pgbus_broadcast_opts(exclude:, visible_to:) do
136
- html = verb == :js ? nil : render_broadcast_html(component)
137
- ops_json = verb == :js ? owner.send(:broadcast_js_ops_json, payload) : nil
140
+ # A broadcast render NEVER inherits the actor's url_options (issue
141
+ # #232): this call may run inside an action request (where the
142
+ # endpoint threaded the actor's host), but subscribers can be on
143
+ # different hosts — absolute URLs in broadcast-rendered components
144
+ # keep the process defaults, so "host-relative URLs" stays the
145
+ # broadcast contract. Off-request callers pay one nil-write pair.
146
+ html = verb == :js ? nil : Phlex::Reactive.with_url_options(nil) { render_broadcast_html(component) }
147
+ ops_json = verb == :js ? broadcast_js_ops_json(payload) : nil
138
148
  keys.each { dispatch_broadcast(verb, it, resolved_target, html, ops_json, morph, effect) }
139
149
  end
140
150
  end
141
151
  end
142
152
 
153
+ # Validate + serialize broadcast ops: reject actor-only ops (focus steals
154
+ # focus in every tab; submit force-submits every subscriber's form;
155
+ # paste_into reads every subscriber's clipboard) and an empty chain (a
156
+ # dead broadcast), then return the JSON wire form. Works on a JS chain
157
+ # (inspect .ops) and a raw array. Lives on the module singleton — the
158
+ # ONE enforcement point both broadcast doors funnel through.
159
+ def broadcast_js_ops_json(ops)
160
+ pairs = ops.is_a?(Phlex::Reactive::JS) ? ops.ops : Array(ops)
161
+ refused = pairs.map { |name, _| name.to_s } & Phlex::Reactive::Streamable::BROADCAST_REFUSED_OPS
162
+ unless refused.empty?
163
+ raise ArgumentError,
164
+ "broadcast_to(js:) refuses actor-only op(s) #{refused.join(", ")} — broadcasting focus " \
165
+ "steals it in every subscriber's tab; broadcasting submit force-submits every " \
166
+ "subscriber's form; broadcasting paste_into reads every subscriber's clipboard. " \
167
+ "These are actor concerns (reply.js / on_client / $ops)."
168
+ end
169
+
170
+ Phlex::Reactive::Response.js_ops_json(ops)
171
+ end
172
+
143
173
  # Render a built component to HTML for a broadcast, ALWAYS instrumented
144
174
  # (issue #185): a Streamable renders through its own render_component (which
145
175
  # fires render.phlex_reactive); a plain component renders through the module
@@ -213,8 +243,12 @@ module Phlex
213
243
  when :prepend then ::Turbo::StreamsChannel.broadcast_prepend_to(*parts, target:, html:, **wire)
214
244
  when :remove then ::Turbo::StreamsChannel.broadcast_remove_to(*parts, target:, **wire)
215
245
  when :js
246
+ # Issue #237: the broadcast op stream carries the verbose gate too
247
+ # (same server env for every subscriber of this payload).
248
+ data = { reactive_ops: ops_json }
249
+ data[:reactive_verbose] = "true" if Phlex::Reactive.verbose_errors
216
250
  ::Turbo::StreamsChannel.broadcast_action_to(
217
- *parts, action: "reactive:js", target:, attributes: { data: { reactive_ops: ops_json } }, render: false
251
+ *parts, action: "reactive:js", target:, attributes: { data: }, render: false
218
252
  )
219
253
  end
220
254
  end
@@ -463,7 +497,7 @@ module Phlex
463
497
  # Define the guided-error stub for each removed broadcast method (issue
464
498
  # #185). `verb` is referenced in define_method AND the message, so the outer
465
499
  # block param must be named — `it` is illegal here.
466
- # rubocop:disable Style/ItBlockParameter
500
+ # rubocop:disable-next Style/ItBlockParameter
467
501
  Phlex::Reactive::Streamable::REMOVED_BROADCASTS.each_key do |verb|
468
502
  define_method(verb) do |*, **|
469
503
  raise NoMethodError,
@@ -471,7 +505,6 @@ module Phlex
471
505
  "use #{name}.#{Phlex::Reactive::Streamable::REMOVED_BROADCASTS[verb]}"
472
506
  end
473
507
  end
474
- # rubocop:enable Style/ItBlockParameter
475
508
 
476
509
  private
477
510
 
@@ -519,23 +552,6 @@ module Phlex
519
552
  # instrumentation + pgbus-threading path for the class-level and module-level
520
553
  # broadcast_to, so the transport logic has exactly one spelling.)
521
554
 
522
- # Validate + serialize broadcast ops: reject actor-only ops (focus steals
523
- # focus in every tab; submit force-submits every subscriber's form) and an
524
- # empty chain (a dead broadcast), then return the JSON wire form. Works on
525
- # a JS chain (inspect .ops) and a raw array.
526
- def broadcast_js_ops_json(ops)
527
- pairs = ops.is_a?(Phlex::Reactive::JS) ? ops.ops : Array(ops)
528
- refused = pairs.map { |name, _| name.to_s } & Phlex::Reactive::Streamable::BROADCAST_REFUSED_OPS
529
- unless refused.empty?
530
- raise ArgumentError,
531
- "broadcast_to(js:) refuses actor-only op(s) #{refused.join(", ")} — broadcasting focus " \
532
- "steals it in every subscriber's tab; broadcasting submit force-submits every " \
533
- "subscriber's form. These are actor concerns (reply.js / on_client / $ops)."
534
- end
535
-
536
- Phlex::Reactive::Response.js_ops_json(ops)
537
- end
538
-
539
555
  def build(model, options)
540
556
  new(**(model ? component_args(model, options) : options))
541
557
  end
@@ -122,7 +122,7 @@ module Phlex
122
122
 
123
123
  # does_not_match? is RSpec's REQUIRED negated-matcher protocol method — its
124
124
  # name is fixed by RSpec, not a predicate we get to rename.
125
- # rubocop:disable Naming/PredicatePrefix
125
+ # rubocop:disable-next Naming/PredicatePrefix
126
126
  def does_not_match?(page)
127
127
  @page = page
128
128
  # The negative is satisfied the moment the property is NOT the expected
@@ -130,7 +130,6 @@ module Phlex
130
130
  # as that holds, false if it stayed equal for the whole budget.
131
131
  poll_until { current_value != @expected }
132
132
  end
133
- # rubocop:enable Naming/PredicatePrefix
134
133
 
135
134
  def failure_message
136
135
  "expected ##{@id} to have value #{@expected.inspect} (its .value property), " \
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.12.3"
5
+ VERSION = "0.12.5"
6
6
  end
7
7
  end
@@ -513,6 +513,22 @@ module Phlex
513
513
  instance = controller_class.new
514
514
  instance.set_request!(request)
515
515
  instance.set_response!(controller_class.make_response!(request))
516
+
517
+ # Issue #232: during a reactive request the endpoint threads the actor's
518
+ # protocol/host/port via with_url_options; merge them over this
519
+ # instance's defaults so absolute URL helpers in a reply render the
520
+ # REQUESTING host. View contexts delegate url_options to their
521
+ # controller (ActionView::RoutingUrlFor), so this one override covers
522
+ # every helper spelling. Off-request (thread-local nil — jobs, console,
523
+ # broadcasts) returns super's frozen memo untouched: zero-alloc, byte-
524
+ # identical URLs. Defined on the singleton because this instance is
525
+ # MEMOIZED per thread — its request (and super's @_url_options) never
526
+ # changes, so the per-call merge is the only per-request seam.
527
+ def instance.url_options
528
+ overrides = Phlex::Reactive.current_url_options
529
+ overrides ? super.merge(overrides) : super
530
+ end
531
+
516
532
  instance.view_context
517
533
  end
518
534
 
@@ -968,6 +984,40 @@ module Phlex
968
984
  Thread.current[:phlex_reactive_connection_id] = previous
969
985
  end
970
986
 
987
+ # The acting request's url_options during a reactive request, or nil
988
+ # (issue #232). Set by the ActionsController (the action AND defer
989
+ # endpoints) so absolute URL helpers in a reply-rendered component —
990
+ # image_tag on an Active Storage attachment being the everyday case —
991
+ # carry the REQUESTING host/port/protocol instead of the process default
992
+ # (the ActiveStorage::SetCurrent move). The memoized off-request view
993
+ # context merges this over its defaults per call (see
994
+ # request_bound_view_context); nil (jobs, console, plain broadcasts)
995
+ # leaves the defaults untouched — byte-identical to before.
996
+ def current_url_options
997
+ Thread.current[:phlex_reactive_url_options]
998
+ end
999
+
1000
+ # Thread `options` for the block, restoring the previous value after.
1001
+ # `nil` CLEARS the actor options — the broadcast render uses this to keep
1002
+ # a broadcast fired inside an action on process defaults (subscribers can
1003
+ # be on different hosts; "URLs in broadcast-rendered components must be
1004
+ # host-relative" is the broadcast contract).
1005
+ def with_url_options(options)
1006
+ previous = Thread.current[:phlex_reactive_url_options]
1007
+ Thread.current[:phlex_reactive_url_options] = options.presence
1008
+ yield
1009
+ ensure
1010
+ Thread.current[:phlex_reactive_url_options] = previous
1011
+ end
1012
+
1013
+ # The url_options trio derived from a live request. `port` is optional_port
1014
+ # — nil on the default port — and is INCLUDED even when nil so a
1015
+ # default-port request clears any configured default port rather than
1016
+ # inheriting it (the merge must own the whole trio).
1017
+ def url_options_for(request)
1018
+ { protocol: request.protocol, host: request.host, port: request.optional_port }
1019
+ end
1020
+
971
1021
  # The controller a correctly-mounted action path resolves to. Used by the
972
1022
  # route guard below.
973
1023
  ACTIONS_CONTROLLER = "phlex/reactive/actions"
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.12.3
4
+ version: 0.12.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -188,13 +188,13 @@ files:
188
188
  - lib/phlex/reactive/test_helpers/system.rb
189
189
  - lib/phlex/reactive/version.rb
190
190
  - lib/tasks/phlex_reactive.rake
191
- homepage: https://github.com/mhenrixon/phlex-reactive
191
+ homepage: https://github.com/zoolutions/phlex-reactive
192
192
  licenses:
193
193
  - MIT
194
194
  metadata:
195
- homepage_uri: https://github.com/mhenrixon/phlex-reactive
196
- source_code_uri: https://github.com/mhenrixon/phlex-reactive/tree/main
197
- changelog_uri: https://github.com/mhenrixon/phlex-reactive/blob/main/CHANGELOG.md
195
+ homepage_uri: https://github.com/zoolutions/phlex-reactive
196
+ source_code_uri: https://github.com/zoolutions/phlex-reactive/tree/main
197
+ changelog_uri: https://github.com/zoolutions/phlex-reactive/blob/main/CHANGELOG.md
198
198
  rubygems_mfa_required: 'true'
199
199
  rdoc_options: []
200
200
  require_paths: