phlex-reactive 0.13.4 → 0.14.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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1050 -630
  3. data/README.md +561 -14
  4. data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
  5. data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
  6. data/app/javascript/phlex/reactive/core.js +181 -0
  7. data/app/javascript/phlex/reactive/core.min.js +4 -0
  8. data/app/javascript/phlex/reactive/core.min.js.map +11 -0
  9. data/app/javascript/phlex/reactive/early.js +175 -0
  10. data/app/javascript/phlex/reactive/early.min.js +4 -0
  11. data/app/javascript/phlex/reactive/early.min.js.map +10 -0
  12. data/app/javascript/phlex/reactive/features/bindings.js +1511 -0
  13. data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
  14. data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
  15. data/app/javascript/phlex/reactive/features/compute.js +541 -0
  16. data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
  17. data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
  18. data/app/javascript/phlex/reactive/features/defer.js +726 -0
  19. data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
  20. data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
  21. data/app/javascript/phlex/reactive/features/devtools.js +242 -0
  22. data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
  23. data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
  24. data/app/javascript/phlex/reactive/features/effects.js +362 -0
  25. data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
  26. data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
  27. data/app/javascript/phlex/reactive/features/form.js +216 -0
  28. data/app/javascript/phlex/reactive/features/form.min.js +4 -0
  29. data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
  30. data/app/javascript/phlex/reactive/features/hints.js +193 -0
  31. data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
  32. data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
  33. data/app/javascript/phlex/reactive/features/persist.js +663 -0
  34. data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
  35. data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
  36. data/app/javascript/phlex/reactive/reactive_controller.js +85 -5821
  37. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  38. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
  39. data/app/javascript/phlex/reactive/runtime.js +3690 -0
  40. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
  41. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
  42. data/lib/phlex/reactive/component/dsl.rb +74 -2
  43. data/lib/phlex/reactive/component/helpers.rb +39 -9
  44. data/lib/phlex/reactive/component/lazy.rb +138 -2
  45. data/lib/phlex/reactive/component/registry.rb +2 -0
  46. data/lib/phlex/reactive/component/selection.rb +71 -0
  47. data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
  48. data/lib/phlex/reactive/doctor.rb +166 -8
  49. data/lib/phlex/reactive/dormant.rb +66 -0
  50. data/lib/phlex/reactive/engine.rb +63 -3
  51. data/lib/phlex/reactive/fragment.rb +233 -0
  52. data/lib/phlex/reactive/inspector/report.rb +24 -3
  53. data/lib/phlex/reactive/inspector.rb +23 -2
  54. data/lib/phlex/reactive/js.rb +17 -2
  55. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
  56. data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
  57. data/lib/phlex/reactive/param_schema.rb +68 -54
  58. data/lib/phlex/reactive/show_conditions.rb +84 -56
  59. data/lib/phlex/reactive/streamable.rb +20 -2
  60. data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
  61. data/lib/phlex/reactive/test_helpers/system.rb +62 -0
  62. data/lib/phlex/reactive/version.rb +1 -1
  63. data/lib/phlex/reactive.rb +84 -0
  64. data/lib/tasks/phlex_reactive.rake +6 -2
  65. metadata +38 -1
@@ -15,7 +15,13 @@ module Phlex
15
15
  # A plain-text table of every component × action, or a JSON array when
16
16
  # `format` is :json. One row per action: component, action, params,
17
17
  # file:line, authorization heuristic.
18
- def actions(components, format: :text)
18
+ # `unverified_only:` keeps just the actions whose authorization_state is
19
+ # :none (the review queue) and drops components left with none.
20
+ def actions(components, format: :text, unverified_only: false)
21
+ if unverified_only
22
+ components = unverified(components)
23
+ return "no unverified actions" if components.empty? && format != :json
24
+ end
19
25
  return actions_json(components) if format == :json
20
26
 
21
27
  actions_text(components)
@@ -34,6 +40,13 @@ module Phlex
34
40
  lines.join("\n")
35
41
  end
36
42
 
43
+ def unverified(components)
44
+ components.filter_map do
45
+ actions = it.actions.select { it.authorization_state == :none }
46
+ it.with(actions:) if actions.any?
47
+ end
48
+ end
49
+
37
50
  # -- plain text -------------------------------------------------------
38
51
 
39
52
  def actions_text(components)
@@ -110,7 +123,9 @@ module Phlex
110
123
  name: action.name,
111
124
  params: action.params,
112
125
  source_location: location_str(action.source_location),
113
- authorization_call_detected: action.authorization_call_detected?
126
+ authorization_call_detected: action.authorization_call_detected?,
127
+ authorization: action.authorization_state.to_s,
128
+ authorization_skip: action.authorization_skip&.to_s
114
129
  }
115
130
  end
116
131
 
@@ -131,8 +146,14 @@ module Phlex
131
146
  end
132
147
 
133
148
  # The authorization heuristic as a short label — advisory only.
149
+ # "authorized*" (call detected), "skipped" / "skipped (class)" (declared
150
+ # skip_verify_authorized), "unverified" (neither).
134
151
  def auth_str(action)
135
- action.authorization_call_detected? ? "authorized*" : "unverified"
152
+ case action.authorization_state
153
+ when :detected then "authorized*"
154
+ when :skipped then action.authorization_skip == :class ? "skipped (class)" : "skipped"
155
+ else "unverified"
156
+ end
136
157
  end
137
158
  end
138
159
  end
@@ -31,7 +31,10 @@ module Phlex
31
31
  # of the definition finds a call to any configured
32
32
  # authorization method or mark_authorized!. A helper
33
33
  # may authorize indirectly, so this is advisory ONLY.
34
- ActionInfo = Data.define(:name, :params, :source_location, :definition) do
34
+ # * authorization_skip — :class (a bare skip_verify_authorized, own or
35
+ # inherited), :action (this action is named in one),
36
+ # or nil. The author's declared opt-out (issue #278).
37
+ ActionInfo = Data.define(:name, :params, :source_location, :definition, :authorization_skip) do
35
38
  # Was any authorization method (or mark_authorized!) called directly in
36
39
  # the method body? Heuristic — a Prism scan of `definition`. False when
37
40
  # the definition is unavailable.
@@ -40,6 +43,14 @@ module Phlex
40
43
 
41
44
  Inspector.authorization_call?(definition)
42
45
  end
46
+
47
+ # :detected (a call was found), :skipped (declared skip_verify_authorized)
48
+ # or :none (neither — the review queue). A detected call wins over a skip.
49
+ def authorization_state
50
+ return :detected if authorization_call_detected?
51
+
52
+ authorization_skip ? :skipped : :none
53
+ end
43
54
  end
44
55
 
45
56
  # One reactive component's introspection.
@@ -164,10 +175,20 @@ module Phlex
164
175
  name: action.name,
165
176
  params: action.params,
166
177
  source_location: location,
167
- definition: (extract_definition(location) if location)
178
+ definition: (extract_definition(location) if location),
179
+ authorization_skip: authorization_skip(klass, action.name)
168
180
  )
169
181
  end
170
182
 
183
+ # How `name` opts out of verify_authorized: :class for a bare skip (own
184
+ # or inherited), :action when listed by name, else nil. Resolved through
185
+ # the same Registry readers the runtime guard uses.
186
+ def authorization_skip(klass, name)
187
+ return :class if klass.reactive_skip_all_authorization?
188
+
189
+ :action if klass.reactive_skip_authorization_actions.include?(name.to_sym)
190
+ end
191
+
171
192
  # The method's [file, line], or nil when the declared method is missing
172
193
  # (an undeclared/typo'd action — the endpoint would 500 on public_send).
173
194
  def method_location(klass, name)
@@ -196,9 +196,24 @@ module Phlex
196
196
  # "submit") interception. ACTOR-ONLY like focus: allowed from on_client /
197
197
  # reply.js / a reducer's $ops, refused in broadcast_to(js:) — a broadcast
198
198
  # submit would force-submit every subscriber's form.
199
+ #
200
+ # submitter: (issue #319) — a CSS selector for the submit control to
201
+ # submit THROUGH (form.requestSubmit(submitter)), so the request carries
202
+ # its name=value — the bulk-action "Delete" that posts action=delete. It
203
+ # resolves with the op's own scoping; one that isn't a submit control of
204
+ # that form warns in the browser and falls back to a plain submit.
205
+ # js.submit("#bulk", submitter: "#delete-submit")
206
+
207
+ def submit(to = :root, global: false, submitter: nil)
208
+ args = target_args(to, global:)
209
+ return append("submit", args) if submitter.nil?
210
+
211
+ unless submitter.is_a?(String) && !submitter.empty?
212
+ raise ArgumentError,
213
+ "js.submit submitter: must be a CSS selector string naming a submit control, got #{submitter.inspect}"
214
+ end
199
215
 
200
- def submit(to = :root, global: false)
201
- append("submit", target_args(to, global:))
216
+ append("submit", args.merge("submitter" => submitter).freeze)
202
217
  end
203
218
 
204
219
  # --- Clipboard-source paste (issue #228) ---
@@ -15,7 +15,9 @@ module Phlex
15
15
  action with its param schema, source file:line, and a heuristic
16
16
  authorization status (whether an authorization method or
17
17
  mark_authorized! call was detected in the body — advisory only, since a
18
- helper may authorize indirectly). Pass `component:` to scope to one
18
+ helper may authorize indirectly), plus `authorization` — detected,
19
+ skipped (declared skip_verify_authorized; `authorization_skip` says
20
+ class or action) or none. Pass `component:` to scope to one
19
21
  component (exact name). Read-only — schemas and paths, never runtime
20
22
  values.
21
23
  DESC
@@ -15,7 +15,8 @@ module Phlex
15
15
  Fuzzy-find reactive components by name (exact > prefix > substring >
16
16
  subsequence, on both the demodulized and full name). Returns the ranked
17
17
  matches, each with its actions' param schema, source location,
18
- authorization heuristic, and the full method-definition SOURCE
18
+ authorization (detected / skipped / none, plus the skip form), and the
19
+ full method-definition SOURCE
19
20
  (extracted with Prism). Read-only.
20
21
 
21
22
  NOTE: unlike the other tools (which report names/paths/schemas only),
@@ -53,6 +54,8 @@ module Phlex
53
54
  params: action.params,
54
55
  source_location: Phlex::Reactive::Inspector::Report.location_str(action.source_location),
55
56
  authorization_call_detected: action.authorization_call_detected?,
57
+ authorization: action.authorization_state.to_s,
58
+ authorization_skip: action.authorization_skip&.to_s,
56
59
  # The full method SOURCE — this is the one field that exposes an
57
60
  # action body verbatim (see the tool description). A secret hardcoded
58
61
  # in an action would surface here; keep secrets out of action bodies.
@@ -334,36 +334,10 @@ module Phlex
334
334
  value.is_a?(Hash) || value.respond_to?(:to_unsafe_h)
335
335
  end
336
336
 
337
- # Unwrap ActionController::Parameters (or a plain Hash) to a string-keyed
338
- # Hash so coercion can index it uniformly, then expand bracket notation so
339
- # a model-scoped Rails form's flat keys nest (issue #21).
340
- def to_param_hash(value)
341
- flat =
342
- if value.respond_to?(:to_unsafe_h)
343
- value.to_unsafe_h.stringify_keys
344
- elsif value.is_a?(Hash)
345
- value.stringify_keys
346
- else
347
- return {}
348
- end
349
-
350
- expand_bracket_keys(flat)
351
- end
352
-
353
- # The client's #collectFields keeps a form input's name verbatim, so a
354
- # Rails Form(model: @invoice) posts FLAT bracketed keys like
355
- # "invoice[date]". Coercion does exact-key matching, so without this a
356
- # nested schema (params: { invoice: { date: … } }) never finds "invoice"
357
- # and drops everything. Expand "invoice[date]" => "2026-01-02" into
358
- # { "invoice" => { "date" => "2026-01-02" } } — and "items[0][qty]" into
359
- # the Rails index-hash form coerce_array already understands — deep-merging
360
- # so sibling keys (invoice[date], invoice[status]) coalesce. Keys WITHOUT
361
- # brackets and already-nested values pass through untouched.
362
- def expand_bracket_keys(flat)
363
- flat.each_with_object({}) do |(key, value), out|
364
- deep_assign(out, bracket_path(key), value)
365
- end
366
- end
337
+ # Each nested hash is unwrapped and expanded by the class-level expand
338
+ # below — the same expansion the endpoint runs before peeling a
339
+ # reactive_scope (issue #337), so both read one shape.
340
+ def to_param_hash(value) = self.class.expand(value)
367
341
 
368
342
  # Matches each bracket segment in "items_attributes][0][qty]" — the part
369
343
  # after the first "[". Hoisted to a frozen constant so coercing a bracketed
@@ -371,35 +345,75 @@ module Phlex
371
345
  BRACKET_SEGMENT = /[^\[\]]+/
372
346
  private_constant :BRACKET_SEGMENT
373
347
 
374
- # "invoice[items_attributes][0][qty]" => ["invoice", "items_attributes",
375
- # "0", "qty"]. A key with no brackets is a single-element path.
376
- def bracket_path(key)
377
- return [key] unless key.include?("[")
348
+ class << self
349
+ # Public so the endpoint can expand BEFORE it peels a reactive_scope
350
+ # (issue #337): a JSON body keeps "todo[title]" as one literal key, which
351
+ # only becomes { "todo" => { "title" => … } } here. Idempotent on input
352
+ # that is already nested (a multipart body Rails expanded itself).
353
+ #
354
+ # Unwraps ActionController::Parameters (or a plain Hash) to a string-keyed
355
+ # Hash so coercion can index it uniformly, then expands bracket notation so
356
+ # a model-scoped Rails form's flat keys nest (issue #21). Anything else is {}.
357
+ def expand(value)
358
+ flat =
359
+ if value.respond_to?(:to_unsafe_h)
360
+ value.to_unsafe_h.stringify_keys
361
+ elsif value.is_a?(Hash)
362
+ value.stringify_keys
363
+ else
364
+ return {}
365
+ end
378
366
 
379
- head, rest = key.split("[", 2)
380
- [head, *rest.scan(BRACKET_SEGMENT)]
381
- end
367
+ expand_bracket_keys(flat)
368
+ end
382
369
 
383
- # Walk/create nested hashes along `path`, then merge `value` at the leaf so
384
- # a bracket key and a sibling pre-nested object coalesce regardless of which
385
- # arrives first. #merge_value deep-merges hash/hash collisions and otherwise
386
- # lets the later value win.
387
- def deep_assign(hash, path, value)
388
- *parents, leaf = path
389
- node = parents.reduce(hash) do |acc, segment|
390
- acc[segment] = {} unless acc[segment].is_a?(Hash)
391
- acc[segment]
370
+ private
371
+
372
+ # The client's #collectFields keeps a form input's name verbatim, so a
373
+ # Rails Form(model: @invoice) posts FLAT bracketed keys like
374
+ # "invoice[date]". Coercion does exact-key matching, so without this a
375
+ # nested schema (params: { invoice: { date: … } }) never finds "invoice"
376
+ # and drops everything. Expand "invoice[date]" => "2026-01-02" into
377
+ # { "invoice" => { "date" => "2026-01-02" } } — and "items[0][qty]" into
378
+ # the Rails index-hash form coerce_array already understands — deep-merging
379
+ # so sibling keys (invoice[date], invoice[status]) coalesce. Keys WITHOUT
380
+ # brackets and already-nested values pass through untouched.
381
+ def expand_bracket_keys(flat)
382
+ flat.each_with_object({}) do |(key, value), out|
383
+ deep_assign(out, bracket_path(key), value)
384
+ end
392
385
  end
393
- node[leaf] = merge_value(node[leaf], value)
394
- end
395
386
 
396
- # Combine an existing leaf value with a new one. Two hashes deep-merge (so
397
- # bracket-expanded fields and a pre-nested object for the same key both
398
- # survive); any other collision takes the new value.
399
- def merge_value(existing, value)
400
- return value unless existing.is_a?(Hash) && value.is_a?(Hash)
387
+ # "invoice[items_attributes][0][qty]" => ["invoice", "items_attributes",
388
+ # "0", "qty"]. A key with no brackets is a single-element path.
389
+ def bracket_path(key)
390
+ return [key] unless key.include?("[")
391
+
392
+ head, rest = key.split("[", 2)
393
+ [head, *rest.scan(BRACKET_SEGMENT)]
394
+ end
401
395
 
402
- existing.merge(value.stringify_keys) { |_k, old, new| merge_value(old, new) }
396
+ # Walk/create nested hashes along `path`, then merge `value` at the leaf so
397
+ # a bracket key and a sibling pre-nested object coalesce regardless of which
398
+ # arrives first. #merge_value deep-merges hash/hash collisions and otherwise
399
+ # lets the later value win.
400
+ def deep_assign(hash, path, value)
401
+ *parents, leaf = path
402
+ node = parents.reduce(hash) do |acc, segment|
403
+ acc[segment] = {} unless acc[segment].is_a?(Hash)
404
+ acc[segment]
405
+ end
406
+ node[leaf] = merge_value(node[leaf], value)
407
+ end
408
+
409
+ # Combine an existing leaf value with a new one. Two hashes deep-merge (so
410
+ # bracket-expanded fields and a pre-nested object for the same key both
411
+ # survive); any other collision takes the new value.
412
+ def merge_value(existing, value)
413
+ return value unless existing.is_a?(Hash) && value.is_a?(Hash)
414
+
415
+ existing.merge(value.stringify_keys) { |_k, old, new| merge_value(old, new) }
416
+ end
403
417
  end
404
418
  end
405
419
  end
@@ -20,6 +20,9 @@ module Phlex
20
20
  # { length: 6 } -> len_eq 6 (issue #226 — the value's
21
21
  # { length: 6.. } / (4..8) CODEPOINT count; Integer Ranges reuse
22
22
  # the threshold vocabulary as len_*)
23
+ # { checked: 1.. } / { checked: 0 } -> checked_* (issue #319 — how many
24
+ # owned boxes of a checkbox GROUP are
25
+ # ticked; same Integer/Range shapes)
23
26
  # Under unless: each term is NEGATED (De Morgan):
24
27
  # scalar -> not; Array -> ∉ (AND of nots); Range -> the complement
25
28
  # predicate (¬gte→lt, ¬lte→gt, ¬lt→gte), and a BOUNDED range complements
@@ -38,6 +41,14 @@ module Phlex
38
41
  # — the shared fixture's emoji vector proves it.
39
42
  LENGTH_KEYS = %w[len_eq len_gte len_gt len_lte len_lt].freeze
40
43
 
44
+ # The checked-count wire keys (issue #319) — the client mirrors them in
45
+ # SHOW_CHECKED_KEYS. They count the TICKED boxes of a checkbox group (an
46
+ # Array value counts its entries; an Integer is the count itself).
47
+ CHECKED_KEYS = %w[checked_eq checked_gte checked_gt checked_lte checked_lt].freeze
48
+
49
+ # A Hash value's one key -> its wire-key prefix.
50
+ COUNT_PREFIXES = { "length" => "len", "checked" => "checked" }.freeze
51
+
41
52
  module_function
42
53
 
43
54
  # Compile if:/if_any:/unless: into DNF groups. Loud validation at render
@@ -136,7 +147,7 @@ module Phlex
136
147
  case value
137
148
  when Range then range_terms(name, value)
138
149
  when Array then [membership_term(name, value)]
139
- when Hash then length_terms(name, value)
150
+ when Hash then count_terms(name, value)
140
151
  else [{ "field" => name, "equals" => equals_literal(value) }]
141
152
  end
142
153
  end
@@ -180,90 +191,95 @@ module Phlex
180
191
  end
181
192
  end
182
193
 
183
- # --- the length predicate (issue #226) ----------------------------------
194
+ # --- the count predicates: length: (#226) and checked: (#319) ---------
184
195
 
185
- # A Hash value names a STRUCTURAL predicate — today only length:. Exact
186
- # Integer -> one len_eq term; an Integer Range -> len_gte/len_lte/len_lt
187
- # terms exactly like range_terms. Length is a total function over the
188
- # value's codepoints (blank/absent -> 0), so { length: 0 } legitimately
189
- # matches a blank field.
190
- def length_terms(name, hash)
191
- value = length_value!(name, hash)
196
+ # A Hash value names a STRUCTURAL predicate over a COUNT — length: (the
197
+ # value's codepoints) or checked: (the ticked boxes of a checkbox group).
198
+ # Exact Integer -> one *_eq term; an Integer Range -> *_gte/*_lte/*_lt
199
+ # terms exactly like range_terms. A count is total (blank/absent -> 0),
200
+ # so { length: 0 } / { checked: 0 } legitimately match a blank field.
201
+ def count_terms(name, hash)
202
+ kind, value = count_value!(name, hash)
203
+ prefix = COUNT_PREFIXES.fetch(kind)
192
204
  case value
193
205
  when Integer
194
- validate_length_literal!(name, value)
195
- [{ "field" => name, "len_eq" => value }]
196
- when Range then length_range_terms(name, value)
197
- else raise_length_kind(name, value)
206
+ validate_count_literal!(name, kind, value)
207
+ [{ "field" => name, "#{prefix}_eq" => value }]
208
+ when Range then count_range_terms(name, kind, value)
209
+ else raise_count_kind(name, kind, value)
198
210
  end
199
211
  end
200
212
 
201
- def length_range_terms(name, range)
213
+ def count_range_terms(name, kind, range)
214
+ prefix = COUNT_PREFIXES.fetch(kind)
202
215
  first = range.begin
203
216
  last = range.end
204
- raise_length_kind(name, range) if first.nil? && last.nil?
205
- [first, last].compact.each { validate_length_literal!(name, it) }
217
+ raise_count_kind(name, kind, range) if first.nil? && last.nil?
218
+ [first, last].compact.each { validate_count_literal!(name, kind, it) }
206
219
 
207
220
  terms = []
208
- terms << { "field" => name, "len_gte" => first } unless first.nil?
221
+ terms << { "field" => name, "#{prefix}_gte" => first } unless first.nil?
209
222
  if last
210
- terms << { "field" => name, (range.exclude_end? ? "len_lt" : "len_lte") => last }
223
+ terms << { "field" => name, "#{prefix}_#{range.exclude_end? ? "lt" : "lte"}" => last }
211
224
  end
212
225
  terms
213
226
  end
214
227
 
215
- # The complement of a length predicate, under unless:. Exact length
216
- # negates to the outside disjunction (len < n OR len > n) — two
228
+ # The complement of a count predicate, under unless:. An exact count
229
+ # negates to the outside disjunction (n < x OR n > x) — two
217
230
  # alternatives, like a bounded numeric range; ranges complement each leg
218
- # (not-gte -> lt, not-lte -> gt, not-lt -> gte). Length is total, so
231
+ # (not-gte -> lt, not-lte -> gt, not-lt -> gte). A count is total, so
219
232
  # unlike the numeric complements there is no blank/NaN fail-closed
220
233
  # asymmetry — the complement is exact.
221
- def length_complement(name, hash)
222
- value = length_value!(name, hash)
234
+ def count_complement(name, hash)
235
+ kind, value = count_value!(name, hash)
236
+ prefix = COUNT_PREFIXES.fetch(kind)
223
237
  case value
224
238
  when Integer
225
- validate_length_literal!(name, value)
226
- [[{ "field" => name, "len_lt" => value }], [{ "field" => name, "len_gt" => value }]]
239
+ validate_count_literal!(name, kind, value)
240
+ [[{ "field" => name, "#{prefix}_lt" => value }], [{ "field" => name, "#{prefix}_gt" => value }]]
227
241
  when Range
228
242
  first = value.begin
229
243
  last = value.end
230
- raise_length_kind(name, value) if first.nil? && last.nil?
231
- [first, last].compact.each { validate_length_literal!(name, it) }
244
+ raise_count_kind(name, kind, value) if first.nil? && last.nil?
245
+ [first, last].compact.each { validate_count_literal!(name, kind, it) }
232
246
 
233
- low = first.nil? ? nil : [{ "field" => name, "len_lt" => first }]
247
+ low = first.nil? ? nil : [{ "field" => name, "#{prefix}_lt" => first }]
234
248
  high =
235
249
  if last.nil? then nil
236
- elsif value.exclude_end? then [{ "field" => name, "len_gte" => last }]
237
- else [{ "field" => name, "len_gt" => last }]
250
+ elsif value.exclude_end? then [{ "field" => name, "#{prefix}_gte" => last }]
251
+ else [{ "field" => name, "#{prefix}_gt" => last }]
238
252
  end
239
253
  [low, high].compact
240
- else raise_length_kind(name, value)
254
+ else raise_count_kind(name, kind, value)
241
255
  end
242
256
  end
243
257
 
244
- # The one Hash key must be length: — anything else is a typo'd predicate
245
- # that must fail at render, never silently in the browser.
246
- def length_value!(name, hash)
247
- unless hash.size == 1 && hash.keys.first.to_s == "length"
258
+ # The one Hash key must be length: or checked: — anything else is a
259
+ # typo'd predicate that must fail at render, never silently in the
260
+ # browser. Returns [kind, value].
261
+ def count_value!(name, hash)
262
+ kind = hash.keys.first.to_s if hash.size == 1
263
+ unless COUNT_PREFIXES.key?(kind)
248
264
  raise ArgumentError,
249
- "reactive_show: #{name.inspect} Hash value supports only length: " \
265
+ "reactive_show: #{name.inspect} Hash value supports only length: or checked: " \
250
266
  "(got #{hash.keys.inspect})"
251
267
  end
252
268
 
253
- hash.values.first
269
+ [kind, hash.values.first]
254
270
  end
255
271
 
256
- def validate_length_literal!(name, value)
272
+ def validate_count_literal!(name, kind, value)
257
273
  return if value.is_a?(Integer) && value >= 0
258
274
 
259
275
  raise ArgumentError,
260
- "reactive_show: #{name.inspect} length: takes a non-negative Integer " \
261
- "(a codepoint count), got #{value.inspect}"
276
+ "reactive_show: #{name.inspect} #{kind}: takes a non-negative Integer " \
277
+ "(a #{kind == "length" ? "codepoint" : "ticked-box"} count), got #{value.inspect}"
262
278
  end
263
279
 
264
- def raise_length_kind(name, value)
280
+ def raise_count_kind(name, kind, value)
265
281
  raise ArgumentError,
266
- "reactive_show: #{name.inspect} length: takes a non-negative Integer " \
282
+ "reactive_show: #{name.inspect} #{kind}: takes a non-negative Integer " \
267
283
  "or an Integer Range, got #{value.inspect}"
268
284
  end
269
285
 
@@ -278,7 +294,7 @@ module Phlex
278
294
  name = field.to_s
279
295
  case value
280
296
  when Range then range_complement(name, value)
281
- when Hash then length_complement(name, value)
297
+ when Hash then count_complement(name, value)
282
298
  when Array
283
299
  list = value.map(&:to_s)
284
300
  raise ArgumentError, "reactive_show: #{name.inspect} Array needs at least one value" if list.empty?
@@ -320,28 +336,40 @@ module Phlex
320
336
  if term.key?("equals") then value == term["equals"]
321
337
  elsif term.key?("not") then value != term["not"]
322
338
  elsif term.key?("in") then term["in"].include?(value)
323
- elsif (key = LENGTH_KEYS.find { term.key?(it) }) then length_term_matches?(key, term[key], value)
339
+ elsif (key = LENGTH_KEYS.find { term.key?(it) }) then count_matches?(key, term[key], value.to_s.length)
340
+ elsif (key = CHECKED_KEYS.find { term.key?(it) }) then count_matches?(key, term[key], checked_count(value))
324
341
  else numeric_term_matches?(term, value)
325
342
  end
326
343
  end
327
344
 
328
- # len_* — compare the value's CODEPOINT count (String#length) against the
329
- # Integer literal. Length is total (blank/absent -> 0), so every value is
330
- # decidable; a malformed non-Integer literal (a hand-built term) is
331
- # fail-closed, mirroring the client's warn-skip.
332
- def length_term_matches?(key, literal, value)
345
+ # len_* / checked_* — compare a count (the value's CODEPOINTS via
346
+ # String#length, or the group's ticked boxes) against the Integer literal.
347
+ # A count is total (blank/absent -> 0), so every value is decidable; a
348
+ # malformed non-Integer literal (a hand-built term) is fail-closed,
349
+ # mirroring the client's warn-skip.
350
+ def count_matches?(key, literal, count)
333
351
  return false unless literal.is_a?(Integer)
334
352
 
335
- length = value.to_s.length
336
- case key
337
- when "len_eq" then length == literal
338
- when "len_gte" then length >= literal
339
- when "len_gt" then length > literal
340
- when "len_lte" then length <= literal
341
- when "len_lt" then length < literal
353
+ case key.delete_prefix("len_").delete_prefix("checked_")
354
+ when "eq" then count == literal
355
+ when "gte" then count >= literal
356
+ when "gt" then count > literal
357
+ when "lte" then count <= literal
358
+ when "lt" then count < literal
342
359
  end
343
360
  end
344
361
 
362
+ # How many boxes of a group are ticked, from the first-paint value: an
363
+ # Array (the checked values) counts its entries, an Integer (or its
364
+ # string) is the count, a lone checkbox's "true"/"false" is 1/0, and a
365
+ # blank/absent group is 0 — the client counts the owned ticked boxes.
366
+ def checked_count(value)
367
+ return value.size if value.is_a?(Array)
368
+ return 1 if value.to_s == "true"
369
+
370
+ Integer(value.to_s, exception: false) || 0
371
+ end
372
+
345
373
  # gte/gt/lte/lt — coerce the field value to a number; a blank/non-numeric
346
374
  # value is fail-closed (never matches). Mirrors the client's
347
375
  # numericPredicateMatches (blank/whitespace -> NaN -> false).
@@ -75,7 +75,7 @@ module Phlex
75
75
  broadcast_remove_to_each: "broadcast_to(each: keys, remove: model)"
76
76
  }.freeze
77
77
 
78
- # Every class that includes Streamable, so the engine can flush their
78
+ # Every class that includes Streamable or subclasses one, so the engine can flush their
79
79
  # memoized view contexts on a Rails code reload (dev) in one pass. A
80
80
  # WeakMap used as a set (the class is the key) so a class reloaded by
81
81
  # Zeitwerk in dev is GC'd normally — a strong Array would pin every
@@ -148,7 +148,15 @@ module Phlex
148
148
  # different hosts — absolute URLs in broadcast-rendered components
149
149
  # keep the process defaults, so "host-relative URLs" stays the
150
150
  # broadcast contract. Off-request callers pay one nil-write pair.
151
- html = verb == :js ? nil : Phlex::Reactive.with_url_options(nil) { render_broadcast_html(component) }
151
+ # Nor the actor's AWAKE dormant roots (issue #274): a subscriber may
152
+ # never have loaded the client, so its copy stays dormant (one
153
+ # fiber-local read off-request).
154
+ html =
155
+ if verb != :js
156
+ Phlex::Reactive::Dormant.asleep do
157
+ Phlex::Reactive.with_url_options(nil) { render_broadcast_html(component) }
158
+ end
159
+ end
152
160
  ops_json = verb == :js ? broadcast_js_ops_json(payload) : nil
153
161
  keys.each { dispatch_broadcast(verb, it, resolved_target, html, ops_json, morph, effect) }
154
162
  end
@@ -313,6 +321,16 @@ module Phlex
313
321
  end
314
322
 
315
323
  class_methods do
324
+ # A subclass inherits Streamable rather than including it, so `included`
325
+ # never fires for it (issue #302). Register it here so the inventory
326
+ # (rake tasks, MCP tools), the doctor and the reload reset see it too.
327
+ # Anonymous subclasses register as well; readers filter to
328
+ # constant-resolvable classes, and the WeakMap lets them be GC'd.
329
+ def inherited(subclass)
330
+ super
331
+ Phlex::Reactive::Streamable.register(subclass)
332
+ end
333
+
316
334
  # The keyword the positional model maps to in `initialize`. For a
317
335
  # record-backed component (Component#reactive_record), this is the SAME
318
336
  # keyword the action endpoint uses in `from_identity` — so one