phlex-reactive 0.12.4 → 0.12.6

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.
@@ -1080,6 +1084,91 @@ module Phlex
1080
1084
  { data: { reactive_show_targets: normalized.to_json } }
1081
1085
  end
1082
1086
 
1087
+ # Client-only localStorage draft over the fields this root OWNS (issue
1088
+ # #239) — "don't make me start over". Spread on the ROOT (mix with
1089
+ # reactive_root, like reactive_show_targets); the generic controller
1090
+ # then writes every owned control's value to localStorage as the user
1091
+ # types (debounced on `input`, immediate on `change`, flushed on
1092
+ # disconnect), restores the draft on the NEXT connect, and forgets it
1093
+ # when the owning form submits successfully (turbo:submit-end), when
1094
+ # `ttl` elapses, or on a js.persist_clear op:
1095
+ #
1096
+ # div(**mix(reactive_root, reactive_persist(key: "village-apply", ttl: 7.days))) do
1097
+ # input(**reactive_field(:name)) # persisted
1098
+ # input(name: "fuckery", **reactive_persist_skip) # honeypot — never
1099
+ # input(type: "hidden", name: "tz") # hidden — never (default)
1100
+ # end
1101
+ #
1102
+ # Never persisted: type=hidden/file/password/submit/button/reset/image,
1103
+ # anything carrying reactive_persist_skip, a nested reactive root's
1104
+ # controls (#15 ownership), and — when `fields:` narrows the set —
1105
+ # any name outside it (scope-aware symbols, the reactive_show form).
1106
+ # `autocomplete="off"` is NOT an implicit skip: honeypots (an
1107
+ # invisible_captcha text input looks like any other) must opt out
1108
+ # explicitly or sit outside the root.
1109
+ #
1110
+ # `restore:` — `:blank` (default) restores a draft value only into a
1111
+ # control the server rendered BLANK, so a 422 re-render's submitted
1112
+ # values win over an older draft; `:always` lets the draft win.
1113
+ #
1114
+ # Same posture as reactive_show: no token, no POST, no expression
1115
+ # surface. Stored values are plain user input replayed via
1116
+ # .value/.checked (never HTML) — a tampered draft can only fill what
1117
+ # the user could type. PII sits in localStorage for `ttl`; the
1118
+ # successful-submit clear and `ttl` are the shared-computer mitigation
1119
+ # (see docs/security). ONE call per root — Phlex `mix` space-joins
1120
+ # duplicate string data values, so a second call would corrupt the JSON.
1121
+ def reactive_persist(key:, ttl: 7.days, fields: nil, restore: :blank, debounce: 300)
1122
+ payload = {
1123
+ "key" => validate_persist_key!(key),
1124
+ "ttl" => validate_persist_ttl!(ttl),
1125
+ "debounce" => validate_persist_debounce!(debounce)
1126
+ }
1127
+ payload["restore"] = "always" if validate_persist_restore!(restore) == :always
1128
+ payload["fields"] = validate_persist_fields!(fields) if fields
1129
+
1130
+ { data: { reactive_persist: payload.to_json } }
1131
+ end
1132
+
1133
+ # Mark ONE control as never persisted (issue #239) — a honeypot, a
1134
+ # one-time code. Spread on the control: input(name: "x", **reactive_persist_skip).
1135
+ def reactive_persist_skip
1136
+ { data: { reactive_persist: "off" } }
1137
+ end
1138
+
1139
+ def validate_persist_key!(key)
1140
+ return key if key.is_a?(String) && !key.strip.empty?
1141
+
1142
+ raise ArgumentError, "reactive_persist key: must be a non-blank String, got #{key.inspect}"
1143
+ end
1144
+
1145
+ # Seconds on the wire: an ActiveSupport::Duration (7.days) or an Integer.
1146
+ def validate_persist_ttl!(ttl)
1147
+ seconds = ttl.is_a?(ActiveSupport::Duration) ? ttl.to_i : ttl
1148
+ return seconds if seconds.is_a?(Integer) && seconds.positive?
1149
+
1150
+ raise ArgumentError, "reactive_persist ttl: must be a positive duration or Integer seconds, got #{ttl.inspect}"
1151
+ end
1152
+
1153
+ def validate_persist_debounce!(debounce)
1154
+ return debounce if debounce.is_a?(Integer) && !debounce.negative?
1155
+
1156
+ raise ArgumentError, "reactive_persist debounce: must be a non-negative Integer (ms), got #{debounce.inspect}"
1157
+ end
1158
+
1159
+ def validate_persist_restore!(restore)
1160
+ return restore if %i[blank always].include?(restore)
1161
+
1162
+ raise ArgumentError, "reactive_persist restore: must be :blank or :always, got #{restore.inspect}"
1163
+ end
1164
+
1165
+ def validate_persist_fields!(fields)
1166
+ list = Array(fields)
1167
+ raise ArgumentError, "reactive_persist fields: needs at least one field name" if list.empty?
1168
+
1169
+ list.map { scoped_field_name(it) }
1170
+ end
1171
+
1083
1172
  # Scoped busy indicator (issue #99). Marks an element so the generic
1084
1173
  # controller toggles `data-reactive-busy` on it ONLY while `action` is in
1085
1174
  # flight — the scoped sibling of the always-on `data-reactive-busy` the
@@ -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`
@@ -252,6 +252,36 @@ module Phlex
252
252
  @ops.empty?
253
253
  end
254
254
 
255
+ # --- Client-only drafts (issue #239) ---
256
+ #
257
+ # persist_state(**state) — merge a FLAT bag of scalars (a wizard's
258
+ # current step) into the root's reactive_persist draft alongside the
259
+ # owned fields' values; the client restores it on the next connect as
260
+ # data-reactive-persist-state on the root plus the
261
+ # reactive:persist-restored event's detail.state. Always targets the
262
+ # ROOT (the draft lives there). persist_clear — forget the draft now
263
+ # (the explicit sibling of the automatic turbo:submit-end clear).
264
+ # Both ACTOR-ONLY like focus/submit: a broadcast that rewrote or wiped
265
+ # every subscriber's draft would be hostile (BROADCAST_REFUSED_OPS).
266
+
267
+ def persist_state(**state)
268
+ raise ArgumentError, "#{self.class}: persist_state needs at least one key (e.g. step: 2)" if state.empty?
269
+
270
+ state.each do |name, value|
271
+ next if value.nil? || [String, Numeric, TrueClass, FalseClass].any? { value.is_a?(it) }
272
+
273
+ raise ArgumentError,
274
+ "#{self.class}: persist_state values must be scalar (String/Numeric/true/false/nil) — " \
275
+ "#{name.inspect} is #{value.class} (the draft stays flat)"
276
+ end
277
+
278
+ append("persist_state", { "to" => ROOT_SENTINEL, "state" => state.transform_keys(&:to_s).freeze }.freeze)
279
+ end
280
+
281
+ def persist_clear
282
+ append("persist_clear", { "to" => ROOT_SENTINEL }.freeze)
283
+ end
284
+
255
285
  private
256
286
 
257
287
  # Immutability: every verb funnels here and returns a NEW frozen chain —
@@ -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) -------------------------------------
@@ -39,7 +39,7 @@ module Phlex
39
39
  # (issue #228) would read every subscriber's clipboard. They belong to
40
40
  # the actor's own reply (reply.js) or gesture (on_client / a reducer's
41
41
  # $ops), never a broadcast. Names mirror Phlex::Reactive::JS's verbs.
42
- BROADCAST_REFUSED_OPS = %w[focus focus_first submit paste_into].freeze
42
+ BROADCAST_REFUSED_OPS = %w[focus focus_first submit paste_into persist_state persist_clear].freeze
43
43
 
44
44
  # The broadcast_to verb kwargs (issue #185) → their Turbo stream action.
45
45
  # SELF-TARGETING verbs derive the target from the component's #id (require a
@@ -137,7 +137,13 @@ module Phlex
137
137
  "broadcast", { component: component_name, stream_action: BROADCAST_VERBS[verb], streamables: keys.size }
138
138
  ) do
139
139
  with_pgbus_broadcast_opts(exclude:, visible_to:) do
140
- html = verb == :js ? nil : render_broadcast_html(component)
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) }
141
147
  ops_json = verb == :js ? broadcast_js_ops_json(payload) : nil
142
148
  keys.each { dispatch_broadcast(verb, it, resolved_target, html, ops_json, morph, effect) }
143
149
  end
@@ -237,8 +243,12 @@ module Phlex
237
243
  when :prepend then ::Turbo::StreamsChannel.broadcast_prepend_to(*parts, target:, html:, **wire)
238
244
  when :remove then ::Turbo::StreamsChannel.broadcast_remove_to(*parts, target:, **wire)
239
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
240
250
  ::Turbo::StreamsChannel.broadcast_action_to(
241
- *parts, action: "reactive:js", target:, attributes: { data: { reactive_ops: ops_json } }, render: false
251
+ *parts, action: "reactive:js", target:, attributes: { data: }, render: false
242
252
  )
243
253
  end
244
254
  end
@@ -487,7 +497,7 @@ module Phlex
487
497
  # Define the guided-error stub for each removed broadcast method (issue
488
498
  # #185). `verb` is referenced in define_method AND the message, so the outer
489
499
  # block param must be named — `it` is illegal here.
490
- # rubocop:disable Style/ItBlockParameter
500
+ # rubocop:disable-next Style/ItBlockParameter
491
501
  Phlex::Reactive::Streamable::REMOVED_BROADCASTS.each_key do |verb|
492
502
  define_method(verb) do |*, **|
493
503
  raise NoMethodError,
@@ -495,7 +505,6 @@ module Phlex
495
505
  "use #{name}.#{Phlex::Reactive::Streamable::REMOVED_BROADCASTS[verb]}"
496
506
  end
497
507
  end
498
- # rubocop:enable Style/ItBlockParameter
499
508
 
500
509
  private
501
510
 
@@ -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.4"
5
+ VERSION = "0.12.6"
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.4
4
+ version: 0.12.6
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: