phlex-reactive 0.12.2 → 0.12.3

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.
@@ -450,6 +450,71 @@ module Phlex
450
450
  Registry.resolve_hash(self, :computes, :reactive_computes)
451
451
  end
452
452
 
453
+ # A fresh client-op chain at CLASS level (issue #226), so a
454
+ # reactive_on_complete declaration reads like its on_client sibling:
455
+ #
456
+ # reactive_on_complete if: { code: { length: 6 } },
457
+ # run: js.dispatch("code:complete")
458
+ #
459
+ # The instance helper (Component::Helpers#js) is unchanged.
460
+ def js
461
+ Phlex::Reactive::JS.new
462
+ end
463
+
464
+ # Declarative completion binding (issue #226): when the declared
465
+ # conditions FIRST become true — evaluated client-side over the owned
466
+ # fields on input/change — run a chain of client DOM ops. The
467
+ # conditions are the SAME if:/if_any:/unless: kwargs reactive_show
468
+ # takes (the ONE ShowConditions language, including the length: form);
469
+ # `run:` is a js chain or a raw [[op, args], …] list (allowlist
470
+ # re-checked, like every raw-ops escape hatch).
471
+ #
472
+ # reactive_on_complete if: { code: { length: 6 } },
473
+ # run: js.dispatch("code:complete")
474
+ # reactive_on_complete :commit, if: { code: { length: 6 } }, run: js.submit
475
+ #
476
+ # RISING-EDGE, actor-gesture semantics on the client: ops fire once on
477
+ # the flip to true, re-arm when the conditions go false, and the
478
+ # connect/morph pass arms WITHOUT firing (a re-render with already-
479
+ # satisfied conditions never self-fires). The optional NAME (default
480
+ # :default) keys the registry so a component can declare several
481
+ # independent bindings; redeclaring a name overrides it (inheritance
482
+ # follows the Registry rules, like reactive_compute).
483
+ def reactive_on_complete(name = :default, run:, **conditions)
484
+ groups = Phlex::Reactive::ShowConditions.normalize(**conditions)
485
+ Registry.write_entry(
486
+ self, :on_completes, name.to_sym,
487
+ OnCompleteDefinition.new(
488
+ name: name.to_sym, conditions: groups,
489
+ ops: normalize_on_complete_ops(name, run)
490
+ )
491
+ )
492
+ end
493
+
494
+ def reactive_on_completes
495
+ Registry.resolve_hash(self, :on_completes, :reactive_on_completes)
496
+ end
497
+
498
+ # The wire JSON for the root's data-reactive-on-complete attr — a
499
+ # frozen array of { any:, ops: } bindings, or nil when undeclared
500
+ # (byte-stable wire). Memoized per class against the registry
501
+ # generation (one integer compare) because reactive_attrs is the
502
+ # token-signing hot path — the reactive_effect_attrs precedent.
503
+ def reactive_on_complete_attr
504
+ registry_gen = Registry.generation
505
+ return @reactive_on_complete_attr if @reactive_on_complete_attr_gen == registry_gen
506
+
507
+ bindings = reactive_on_completes
508
+ @reactive_on_complete_attr =
509
+ if bindings.empty?
510
+ nil
511
+ else
512
+ bindings.values.map { { "any" => it.conditions, "ops" => it.ops } }.to_json.freeze
513
+ end
514
+ @reactive_on_complete_attr_gen = registry_gen
515
+ @reactive_on_complete_attr
516
+ end
517
+
453
518
  # Fetch one compute definition — the reader form matching
454
519
  # reactive_collection_def (issue #115). The bare reactive_compute(name)
455
520
  # getter above is a PERMANENT documented alias (it shipped in the same
@@ -480,6 +545,25 @@ module Phlex
480
545
  # nil types, so the array-form wire stays byte-identical and the client
481
546
  # keeps its numeric coercion.
482
547
  #
548
+ # Validate + normalize a reactive_on_complete run: chain (issue #226)
549
+ # to the raw [[op, args], …] pairs. A js chain is already validated at
550
+ # build time; a raw list re-runs the attr allowlist — the same
551
+ # defense-in-depth every raw-ops escape hatch gets (js([...]) /
552
+ # broadcast_to(js: [...])). Loud on anything else — a dead or hostile
553
+ # binding must fail at class load, never no-op in the browser.
554
+ def normalize_on_complete_ops(name, run)
555
+ pairs = run.is_a?(Phlex::Reactive::JS) ? run.ops : run
556
+ unless pairs.is_a?(Array)
557
+ raise ArgumentError,
558
+ "reactive_on_complete #{name.inspect} run: takes a js chain " \
559
+ "(js.dispatch(\"code:complete\")) or a raw [[op, args], ...] list, got #{run.class}"
560
+ end
561
+ raise ArgumentError, "reactive_on_complete #{name.inspect} got no ops — a dead binding" if pairs.empty?
562
+
563
+ Phlex::Reactive::JS.assert_ops_allowed!(pairs) unless run.is_a?(Phlex::Reactive::JS)
564
+ pairs
565
+ end
566
+
483
567
  # Named after the shape it normalizes.
484
568
  def normalize_compute_inputs(inputs)
485
569
  return normalize_typed_compute_inputs(inputs) if inputs.is_a?(Hash)
@@ -98,6 +98,14 @@ module Phlex
98
98
  if self.class.respond_to?(:reactive_effect_attrs) && (effects = self.class.reactive_effect_attrs)
99
99
  data.merge!(effects)
100
100
  end
101
+ # Completion bindings (issue #226): the declared reactive_on_complete
102
+ # bindings ride the root as ONE JSON attr the client evaluates over
103
+ # the owned fields (rising edge → run the ops). nil when undeclared —
104
+ # no key, byte-stable wire. Per-class memo (one integer compare).
105
+ if self.class.respond_to?(:reactive_on_complete_attr) &&
106
+ (on_complete = self.class.reactive_on_complete_attr)
107
+ data[:reactive_on_complete] = on_complete
108
+ end
101
109
  { data: }
102
110
  end
103
111
 
@@ -381,6 +389,14 @@ module Phlex
381
389
  raise ArgumentError, "on_client(#{event.inspect}) got no ops — a dead trigger" if ops.empty?
382
390
 
383
391
  event = event.to_s
392
+ # Issue #226: requestSubmit dispatches the very `submit` event this
393
+ # trigger would be bound to — an infinite loop. Loud at render.
394
+ if event == "submit" && ops.ops.any? { |name, _| name == "submit" }
395
+ raise ArgumentError,
396
+ "on_client(:submit, js.submit) would re-fire itself — requestSubmit dispatches the " \
397
+ "submit event this trigger is bound to. Bind the submit op to another event " \
398
+ "(change/input), or gate it behind a reducer's $ops / reactive_on_complete."
399
+ end
384
400
  window_bound = window || outside
385
401
  attrs = {
386
402
  data: {
@@ -50,6 +50,8 @@ module Phlex
50
50
  state_keys: :@reactive_own_state_keys,
51
51
  collections: :@reactive_own_collections,
52
52
  computes: :@reactive_own_computes,
53
+ # Completion bindings — reactive_on_complete (issue #226).
54
+ on_completes: :@reactive_own_on_completes,
53
55
  record_key: :@reactive_own_record_key,
54
56
  # Form-field namespace — reactive_scope (issue #180).
55
57
  scope: :@reactive_own_scope,
@@ -104,6 +104,15 @@ module Phlex
104
104
  # default-deny; the client interpreter re-checks the shape).
105
105
  ComputeDefinition = Data.define(:name, :inputs, :outputs, :reducer, :input_types, :mirror)
106
106
 
107
+ # A declared completion binding (issue #226): `conditions` are the
108
+ # pre-compiled ShowConditions DNF groups (the ONE conditions language,
109
+ # including the #226 length: form); `ops` is the validated [[op, args],…]
110
+ # client-op chain. The generic controller evaluates the conditions over
111
+ # the owned fields on input/change and runs the ops on the RISING EDGE —
112
+ # the conditions' first flip to true — through the same frozen CLIENT_OPS
113
+ # whitelist as on_client. The connect/morph pass arms without firing.
114
+ OnCompleteDefinition = Data.define(:name, :conditions, :ops)
115
+
107
116
  # A declared add/remove-row collection (issue #35): the list contract tied
108
117
  # into one unit — the per-row item component, the container DOM id rows
109
118
  # live in, an optional companion count id, an optional empty-state
@@ -170,6 +170,21 @@ module Phlex
170
170
  append("focus_first", target_args(to, global:))
171
171
  end
172
172
 
173
+ # --- Submit (issue #226) ---
174
+ #
175
+ # submit(to = :root) — requestSubmit() the TARGET'S OWN form: the target
176
+ # itself when it IS a form, else its form owner (input.form, honoring a
177
+ # form= attribute), else the nearest ancestor form. requestSubmit runs
178
+ # constraint validation and fires a REAL cancelable `submit` event, so it
179
+ # composes with both a native/Turbo form and an on(:action, event:
180
+ # "submit") interception. ACTOR-ONLY like focus: allowed from on_client /
181
+ # reply.js / a reducer's $ops, refused in broadcast_to(js:) — a broadcast
182
+ # submit would force-submit every subscriber's form.
183
+
184
+ def submit(to = :root, global: false)
185
+ append("submit", target_args(to, global:))
186
+ end
187
+
173
188
  # --- Text content (issue #159) ---
174
189
  #
175
190
  # text(to, value) — set the target's textContent (stringified; nil clears).
@@ -17,6 +17,9 @@ module Phlex
17
17
  # (..10) -> lte 10
18
18
  # (...10) -> lt 10
19
19
  # (10..20) -> gte 10 AND lte 20 (two terms, one group)
20
+ # { length: 6 } -> len_eq 6 (issue #226 — the value's
21
+ # { length: 6.. } / (4..8) CODEPOINT count; Integer Ranges reuse
22
+ # the threshold vocabulary as len_*)
20
23
  # Under unless: each term is NEGATED (De Morgan):
21
24
  # scalar -> not; Array -> ∉ (AND of nots); Range -> the complement
22
25
  # predicate (¬gte→lt, ¬lte→gt, ¬lt→gte), and a BOUNDED range complements
@@ -29,6 +32,12 @@ module Phlex
29
32
  # bounded-range complement splits). There is NO expression surface: every
30
33
  # term is a declared literal predicate — the pre-#180 default-deny posture.
31
34
  module ShowConditions
35
+ # The length-predicate wire keys (issue #226) — the Ruby half; the client
36
+ # mirrors them in SHOW_LENGTH_KEYS. Length is counted in CODEPOINTS
37
+ # (String#length here, [...value].length there) so multibyte values agree
38
+ # — the shared fixture's emoji vector proves it.
39
+ LENGTH_KEYS = %w[len_eq len_gte len_gt len_lte len_lt].freeze
40
+
32
41
  module_function
33
42
 
34
43
  # Compile if:/if_any:/unless: into DNF groups. Loud validation at render
@@ -128,6 +137,7 @@ module Phlex
128
137
  case value
129
138
  when Range then range_terms(name, value)
130
139
  when Array then [membership_term(name, value)]
140
+ when Hash then length_terms(name, value)
131
141
  else [{ "field" => name, "equals" => equals_literal(value) }]
132
142
  end
133
143
  end
@@ -171,6 +181,93 @@ module Phlex
171
181
  end
172
182
  end
173
183
 
184
+ # --- the length predicate (issue #226) ----------------------------------
185
+
186
+ # A Hash value names a STRUCTURAL predicate — today only length:. Exact
187
+ # Integer -> one len_eq term; an Integer Range -> len_gte/len_lte/len_lt
188
+ # terms exactly like range_terms. Length is a total function over the
189
+ # value's codepoints (blank/absent -> 0), so { length: 0 } legitimately
190
+ # matches a blank field.
191
+ def length_terms(name, hash)
192
+ value = length_value!(name, hash)
193
+ case value
194
+ when Integer
195
+ validate_length_literal!(name, value)
196
+ [{ "field" => name, "len_eq" => value }]
197
+ when Range then length_range_terms(name, value)
198
+ else raise_length_kind(name, value)
199
+ end
200
+ end
201
+
202
+ def length_range_terms(name, range)
203
+ first = range.begin
204
+ last = range.end
205
+ raise_length_kind(name, range) if first.nil? && last.nil?
206
+ [first, last].compact.each { validate_length_literal!(name, it) }
207
+
208
+ terms = []
209
+ terms << { "field" => name, "len_gte" => first } unless first.nil?
210
+ if last
211
+ terms << { "field" => name, (range.exclude_end? ? "len_lt" : "len_lte") => last }
212
+ end
213
+ terms
214
+ end
215
+
216
+ # The complement of a length predicate, under unless:. Exact length
217
+ # negates to the outside disjunction (len < n OR len > n) — two
218
+ # alternatives, like a bounded numeric range; ranges complement each leg
219
+ # (not-gte -> lt, not-lte -> gt, not-lt -> gte). Length is total, so
220
+ # unlike the numeric complements there is no blank/NaN fail-closed
221
+ # asymmetry — the complement is exact.
222
+ def length_complement(name, hash)
223
+ value = length_value!(name, hash)
224
+ case value
225
+ when Integer
226
+ validate_length_literal!(name, value)
227
+ [[{ "field" => name, "len_lt" => value }], [{ "field" => name, "len_gt" => value }]]
228
+ when Range
229
+ first = value.begin
230
+ last = value.end
231
+ raise_length_kind(name, value) if first.nil? && last.nil?
232
+ [first, last].compact.each { validate_length_literal!(name, it) }
233
+
234
+ low = first.nil? ? nil : [{ "field" => name, "len_lt" => first }]
235
+ high =
236
+ if last.nil? then nil
237
+ elsif value.exclude_end? then [{ "field" => name, "len_gte" => last }]
238
+ else [{ "field" => name, "len_gt" => last }]
239
+ end
240
+ [low, high].compact
241
+ else raise_length_kind(name, value)
242
+ end
243
+ end
244
+
245
+ # The one Hash key must be length: — anything else is a typo'd predicate
246
+ # that must fail at render, never silently in the browser.
247
+ def length_value!(name, hash)
248
+ unless hash.size == 1 && hash.keys.first.to_s == "length"
249
+ raise ArgumentError,
250
+ "reactive_show: #{name.inspect} Hash value supports only length: " \
251
+ "(got #{hash.keys.inspect})"
252
+ end
253
+
254
+ hash.values.first
255
+ end
256
+
257
+ def validate_length_literal!(name, value)
258
+ return if value.is_a?(Integer) && value >= 0
259
+
260
+ raise ArgumentError,
261
+ "reactive_show: #{name.inspect} length: takes a non-negative Integer " \
262
+ "(a codepoint count), got #{value.inspect}"
263
+ end
264
+
265
+ def raise_length_kind(name, value)
266
+ raise ArgumentError,
267
+ "reactive_show: #{name.inspect} length: takes a non-negative Integer " \
268
+ "or an Integer Range, got #{value.inspect}"
269
+ end
270
+
174
271
  # --- the value language (negated, under unless:) -----------------------
175
272
 
176
273
  # A field => value under unless: -> a LIST of alternative term-lists.
@@ -182,6 +279,7 @@ module Phlex
182
279
  name = field.to_s
183
280
  case value
184
281
  when Range then range_complement(name, value)
282
+ when Hash then length_complement(name, value)
185
283
  when Array
186
284
  list = value.map(&:to_s)
187
285
  raise ArgumentError, "reactive_show: #{name.inspect} Array needs at least one value" if list.empty?
@@ -223,10 +321,28 @@ module Phlex
223
321
  if term.key?("equals") then value == term["equals"]
224
322
  elsif term.key?("not") then value != term["not"]
225
323
  elsif term.key?("in") then term["in"].include?(value)
324
+ elsif (key = LENGTH_KEYS.find { term.key?(it) }) then length_term_matches?(key, term[key], value)
226
325
  else numeric_term_matches?(term, value)
227
326
  end
228
327
  end
229
328
 
329
+ # len_* — compare the value's CODEPOINT count (String#length) against the
330
+ # Integer literal. Length is total (blank/absent -> 0), so every value is
331
+ # decidable; a malformed non-Integer literal (a hand-built term) is
332
+ # fail-closed, mirroring the client's warn-skip.
333
+ def length_term_matches?(key, literal, value)
334
+ return false unless literal.is_a?(Integer)
335
+
336
+ length = value.to_s.length
337
+ case key
338
+ when "len_eq" then length == literal
339
+ when "len_gte" then length >= literal
340
+ when "len_gt" then length > literal
341
+ when "len_lte" then length <= literal
342
+ when "len_lt" then length < literal
343
+ end
344
+ end
345
+
230
346
  # gte/gt/lte/lt — coerce the field value to a number; a blank/non-numeric
231
347
  # value is fail-closed (never matches). Mirrors the client's
232
348
  # numericPredicateMatches (blank/whitespace -> NaN -> false).
@@ -33,9 +33,12 @@ 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): a js broadcast rejects a
37
- # broadcast that carries one. Names mirror Phlex::Reactive::JS's focus verbs.
38
- BROADCAST_REFUSED_OPS = %w[focus focus_first].freeze
36
+ # Actor-only ops: a js broadcast rejects a broadcast that carries one.
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
39
+ # the actor's own reply (reply.js) or gesture (on_client / a reducer's
40
+ # $ops), never a broadcast. Names mirror Phlex::Reactive::JS's verbs.
41
+ BROADCAST_REFUSED_OPS = %w[focus focus_first submit].freeze
39
42
 
40
43
  # The broadcast_to verb kwargs (issue #185) → their Turbo stream action.
41
44
  # SELF-TARGETING verbs derive the target from the component's #id (require a
@@ -516,16 +519,18 @@ module Phlex
516
519
  # instrumentation + pgbus-threading path for the class-level and module-level
517
520
  # broadcast_to, so the transport logic has exactly one spelling.)
518
521
 
519
- # Validate + serialize broadcast ops: reject focus-class ops (they steal
520
- # focus in every tab) and an empty chain (a dead broadcast), then return
521
- # the JSON wire form. Works on a JS chain (inspect .ops) and a raw array.
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.
522
526
  def broadcast_js_ops_json(ops)
523
527
  pairs = ops.is_a?(Phlex::Reactive::JS) ? ops.ops : Array(ops)
524
528
  refused = pairs.map { |name, _| name.to_s } & Phlex::Reactive::Streamable::BROADCAST_REFUSED_OPS
525
529
  unless refused.empty?
526
530
  raise ArgumentError,
527
- "broadcast_js_to refuses focus op(s) #{refused.join(", ")} — broadcasting focus " \
528
- "steals it in every subscriber's tab. Focus is an actor-reply concern (reply.js)."
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)."
529
534
  end
530
535
 
531
536
  Phlex::Reactive::Response.js_ops_json(ops)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.12.2"
5
+ VERSION = "0.12.3"
6
6
  end
7
7
  end
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.2
4
+ version: 0.12.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson