phlex-reactive 0.13.3 → 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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1097 -630
  3. data/README.md +638 -16
  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/inspect.js +21 -9
  37. data/app/javascript/phlex/reactive/inspect.min.js +2 -2
  38. data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
  39. data/app/javascript/phlex/reactive/reactive_controller.js +85 -5580
  40. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  41. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
  42. data/app/javascript/phlex/reactive/runtime.js +3690 -0
  43. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
  44. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
  45. data/lib/phlex/reactive/component/dsl.rb +74 -2
  46. data/lib/phlex/reactive/component/helpers.rb +104 -28
  47. data/lib/phlex/reactive/component/lazy.rb +138 -2
  48. data/lib/phlex/reactive/component/registry.rb +2 -0
  49. data/lib/phlex/reactive/component/selection.rb +71 -0
  50. data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
  51. data/lib/phlex/reactive/doctor.rb +166 -8
  52. data/lib/phlex/reactive/dormant.rb +66 -0
  53. data/lib/phlex/reactive/engine.rb +64 -3
  54. data/lib/phlex/reactive/fragment.rb +233 -0
  55. data/lib/phlex/reactive/inspector/report.rb +24 -3
  56. data/lib/phlex/reactive/inspector.rb +23 -2
  57. data/lib/phlex/reactive/js.rb +61 -12
  58. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
  59. data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
  60. data/lib/phlex/reactive/param_schema.rb +68 -54
  61. data/lib/phlex/reactive/pending/markup.rb +128 -0
  62. data/lib/phlex/reactive/pending.rb +25 -21
  63. data/lib/phlex/reactive/show_conditions.rb +84 -56
  64. data/lib/phlex/reactive/streamable.rb +20 -2
  65. data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
  66. data/lib/phlex/reactive/test_helpers/system.rb +62 -0
  67. data/lib/phlex/reactive/version.rb +1 -1
  68. data/lib/phlex/reactive.rb +84 -0
  69. data/lib/tasks/phlex_reactive.rake +6 -2
  70. metadata +39 -1
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Pending
6
+ # The opt-in pending MARKUP hook (issue #249). The #248 markers dim a live
7
+ # row; some apps want the pending state to be honest markup instead — a
8
+ # real "Queued" badge, a button that is genuinely gone. A row component
9
+ # opts in by defining `pending_template` (private is fine), and
10
+ # reply.pending then ALSO replaces the row with that markup:
11
+ #
12
+ # class TransferRow < ApplicationComponent
13
+ # def view_template = li(id:, **reactive_attrs) { ...; button(**on(:retry)) { "Retry" } }
14
+ #
15
+ # private
16
+ #
17
+ # def pending_template = li(id:, **reactive_attrs) { ...; span(class: "badge") { "Queued" } }
18
+ # end
19
+ #
20
+ # It is a TEMPLATE method, not a content method like `deferred_placeholder`
21
+ # (#165): there the gem owns the shell div and the component supplies what
22
+ # goes inside; here the row's own root is swapped, so the hook renders it.
23
+ #
24
+ # ## Why a subclass
25
+ #
26
+ # Phlex 2 compiles templates per class, so the swap cannot be a per-instance
27
+ # method. Each opted-in row class gets ONE memoized anonymous subclass whose
28
+ # view_template is the pending template. Two contracts make that safe:
29
+ #
30
+ # * The variant's `.name` is the PARENT's. A row that includes Component
31
+ # signs its class name into its token; an anonymous name cannot
32
+ # constantize, so the row's next action would 400.
33
+ # * The memo holds classes derived from reloadable ones, so the engine
34
+ # resets it on Rails code reload (config.to_prepare), exactly like the
35
+ # view-context and stream-builder memos.
36
+ #
37
+ # ## Un-pending is the settle's job
38
+ #
39
+ # The markers are attributes, so any settle can strip them. Swapped markup
40
+ # is not: only a settle that REPLACES or REMOVES the row puts it back. A
41
+ # flash-only settle, `finish: true` or a failed job clears the markers but
42
+ # leaves the pending markup in place.
43
+ module Markup
44
+ HOOK = :pending_template
45
+
46
+ # The root start tag of the rendered markup, walked quote-aware: Phlex
47
+ # double-quotes every attribute value and leaves `>` raw inside it
48
+ # (`data-action="click->reactive#dispatch"`), so the tag must not end at
49
+ # the first `>`.
50
+ ROOT_TAG = %r{\A\s*<[^\s>/]+(?:\s+[^\s=>/]+(?:="[^"]*")?)*}
51
+
52
+ # Keyed weakly on the row class, so a class Zeitwerk replaced is not
53
+ # pinned between resets. A WeakKeyMap, not a WeakMap: nothing else holds
54
+ # the variant, so a weak VALUE would let GC drop the memo.
55
+ @variants = ObjectSpace::WeakKeyMap.new
56
+ @mutex = Mutex.new
57
+
58
+ class << self
59
+ # The row's pending markup as HTML, or nil when the row has no hook
60
+ # (or the target is a bare DOM id String — there is no component to
61
+ # render). Rendered through the PARENT's render_component: the variant
62
+ # is never registered with Streamable, so this reuses the parent's
63
+ # memoized, reload-reset view context and its instrumentation name.
64
+ def render(row)
65
+ return nil unless row.is_a?(Phlex::Reactive::Streamable) && row.respond_to?(HOOK, true)
66
+
67
+ html = row.class.render_component(variant_instance(row))
68
+ assert_keeps_id!(row, html)
69
+ html
70
+ end
71
+
72
+ # The replace stream that swaps one row to its pending markup.
73
+ def stream(target_id, html)
74
+ Phlex::Reactive::Stream.wrap(
75
+ Phlex::Reactive.stream_builder.replace(target_id, html: html.html_safe),
76
+ action: "replace", target: target_id, renders_root: true
77
+ )
78
+ end
79
+
80
+ def variant_for(row_class)
81
+ @mutex.synchronize { @variants[row_class] ||= build_variant(row_class) }
82
+ end
83
+
84
+ # Called from the engine's config.to_prepare (Rails code reload).
85
+ def reset!
86
+ @mutex.synchronize { @variants = ObjectSpace::WeakKeyMap.new }
87
+ end
88
+
89
+ private
90
+
91
+ def build_variant(row_class)
92
+ Class.new(row_class) do
93
+ def self.name = superclass.name
94
+ def self.inspect = "#{superclass.name}(pending)"
95
+ def self.to_s = inspect
96
+
97
+ def view_template = pending_template
98
+ end
99
+ end
100
+
101
+ # A variant instance carrying the built row's state. allocate + copy
102
+ # rather than re-running initialize: the row may have been handed to
103
+ # reply.pending already built, with arguments the gem never saw.
104
+ def variant_instance(row)
105
+ variant = variant_for(row.class).allocate
106
+ row.instance_variables.each { variant.instance_variable_set(it, row.instance_variable_get(it)) }
107
+ variant
108
+ end
109
+
110
+ # A pending template that drops the row's id leaves the settle nothing
111
+ # to target: its replace/remove would miss and the row would say
112
+ # "Queued" forever. Fail at the action, before anything is enqueued.
113
+ # Only the ROOT start tag counts — the id on a data- attribute or a
114
+ # nested child is not a target the settle can swap (see ROOT_TAG).
115
+ def assert_keeps_id!(row, html)
116
+ root_tag = html[ROOT_TAG].to_s
117
+ return if root_tag.match?(/\sid="#{Regexp.escape(ERB::Util.html_escape(row.id))}"/)
118
+
119
+ raise Phlex::Reactive::Error,
120
+ "#{row.class.name}#pending_template must render the row's root with its id " \
121
+ "(id=\"#{row.id}\") — the settle targets that id to replace or remove the row. " \
122
+ "Render the root as in view_template, e.g. li(id:, **reactive_attrs) { ... }."
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -92,8 +92,10 @@ module Phlex
92
92
  end
93
93
 
94
94
  # One recorded pending segment: the handle plus the DOM ids that were
95
- # marked pending (the endpoint emits one marker stream per id).
96
- Segment = Data.define(:handle, :target_ids)
95
+ # marked pending (the endpoint emits one marker stream per id), and — in
96
+ # step with target_ids — each row's pending markup (issue #249), nil for
97
+ # a row without the pending_template hook.
98
+ Segment = Data.define(:handle, :target_ids, :markup)
97
99
 
98
100
  # The attribute apps style. Set alongside aria-busy on every pending
99
101
  # target AND on the container, so one CSS rule covers both:
@@ -126,7 +128,8 @@ module Phlex
126
128
  # or re-queries, so a concurrent write could make the marked rows and
127
129
  # the enqueued jobs disagree.
128
130
  list = materialize(records)
129
- targets = resolve_targets(container, list, collection)
131
+ rows = resolve_rows(container, list, collection)
132
+ targets = rows.map { it.is_a?(String) ? it : it.id }
130
133
 
131
134
  unless Phlex::Reactive.settle_capable?
132
135
  warn_no_lane
@@ -134,9 +137,12 @@ module Phlex
134
137
  return nil
135
138
  end
136
139
 
140
+ # Rendered BEFORE the enqueue, so a broken pending_template fails the
141
+ # action (and rolls it back) before any job exists.
142
+ markup = rows.map { Markup.render(it) }
137
143
  handle = build_handle(container, collection, targets, peers)
138
144
  with_handle(handle) { run_enqueue(list, job, args, enqueue, targets) }
139
- Segment.new(handle:, target_ids: targets)
145
+ Segment.new(handle:, target_ids: targets, markup:)
140
146
  end
141
147
 
142
148
  # One record, or an enumerable of them, as an Array — never re-walked.
@@ -149,9 +155,13 @@ module Phlex
149
155
 
150
156
  # The wire streams for one segment, in apply order: the per-target
151
157
  # pending markers FIRST (so the UI stops lying immediately), then the
152
- # single subscription directive.
158
+ # single subscription directive. A row with pending markup is replaced
159
+ # just before its marker, so the marker lands on the swapped node.
153
160
  def streams_for(segment)
154
- streams = segment.target_ids.map { marker_stream(it) }
161
+ streams = segment.target_ids.each_with_index.flat_map do |id, index|
162
+ html = segment.markup[index]
163
+ html ? [Markup.stream(id, html), marker_stream(id)] : [marker_stream(id)]
164
+ end
155
165
  streams << marker_stream(segment.handle.anchor)
156
166
  streams << directive_stream(segment.handle)
157
167
  streams
@@ -162,24 +172,18 @@ module Phlex
162
172
  # exact id to tear the subscription down.
163
173
  def source_id(anchor) = "reactive-defer-src-#{anchor}"
164
174
 
165
- # Resolve the DOM id a row component would render for `model`, WITHOUT
166
- # rendering it (build is cheap — #id must be render-context-free, that
167
- # is the Streamable#id contract).
168
- def row_dom_id(definition, model)
169
- return model if model.is_a?(String)
170
-
171
- definition.item.send(:build, model, {}).id
172
- end
173
-
174
175
  private
175
176
 
176
- # Each pending target, as a DOM id. With `in:` the rows resolve through
177
- # the collection declaration; without it every entry must already be a
178
- # Streamable component (its own #id is the target).
179
- def resolve_targets(container, list, collection)
177
+ # Each pending target, as a row component (its #id is the target, and
178
+ # it renders the pending markup when it has the hook) or a bare DOM id
179
+ # String. With `in:` the rows resolve through the collection declaration
180
+ # (build is cheap — #id must be render-context-free, the Streamable#id
181
+ # contract); without it every entry must already be a Streamable
182
+ # component.
183
+ def resolve_rows(container, list, collection)
180
184
  if collection
181
185
  definition = Phlex::Reactive::Collections.definition!(container, collection)
182
- return list.map { row_dom_id(definition, it) }
186
+ return list.map { it.is_a?(String) ? it : definition.item.send(:build, it, {}) }
183
187
  end
184
188
 
185
189
  list.map do
@@ -190,7 +194,7 @@ module Phlex
190
194
  "Streamable component instance (its #id is the target)"
191
195
  end
192
196
 
193
- it.id
197
+ it
194
198
  end
195
199
  end
196
200
 
@@ -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
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Phlex
6
+ module Reactive
7
+ module TestHelpers
8
+ module System
9
+ # The waiting matcher behind have_reactive_requests (issue #279). Reads the
10
+ # client's per-kind request totals (<html data-reactive-requests>, JSON)
11
+ # AND the in-flight marker in ONE evaluate_script per poll, and holds once
12
+ # the selected total equals `expected` with the layer idle, so a request
13
+ # still in flight is never counted early. It cannot see a request that has
14
+ # not STARTED yet (a debounced trigger, or the defer an action's reply is
15
+ # about to start): barrier on the UI outcome first for those.
16
+ #
17
+ # Totals only grow until reset_reactive_requests!, so a total ABOVE the
18
+ # expectation is terminal: the matcher fails at once instead of waiting out
19
+ # the budget. A plain class (no RSpec::Matchers.define), like
20
+ # ReactiveValueMatcher, so System stays loadable under Capybara alone.
21
+ class ReactiveRequestsMatcher
22
+ KINDS = %i[action defer].freeze
23
+
24
+ def initialize(expected, kind: nil, wait: nil)
25
+ unless kind.nil? || KINDS.include?(kind)
26
+ raise ArgumentError, "unknown reactive request kind #{kind.inspect} (expected one of #{KINDS.inspect} or nil)"
27
+ end
28
+
29
+ @expected = Integer(expected)
30
+ @kind = kind
31
+ @wait = wait
32
+ end
33
+
34
+ def matches?(page)
35
+ @page = page
36
+ poll_until do
37
+ read_state
38
+ break false if selected > @expected
39
+
40
+ selected == @expected && !@active
41
+ end
42
+ end
43
+
44
+ # RSpec's required negated-matcher protocol name, not a predicate we pick.
45
+ # rubocop:disable-next Naming/PredicatePrefix
46
+ def does_not_match?(page)
47
+ @page = page
48
+ poll_until do
49
+ read_state
50
+ selected != @expected && !@active
51
+ end
52
+ end
53
+
54
+ def failure_message
55
+ with_gate_hint(
56
+ "expected #{@expected} reactive #{noun} (#{scope}), got #{selected} — totals #{totals_str}"
57
+ )
58
+ end
59
+
60
+ def failure_message_when_negated
61
+ with_gate_hint(
62
+ "expected the reactive request count (#{scope}) NOT to be #{@expected}, but it was — totals #{totals_str}"
63
+ )
64
+ end
65
+
66
+ private
67
+
68
+ # A closed verbose gate is the usual cause of an unexpected zero: say so.
69
+ def with_gate_hint(message)
70
+ return message if @verbose
71
+
72
+ "#{message}. The verbose gate is closed, so the client is not counting: it writes " \
73
+ "<html #{REQUESTS_ATTR}> only under Phlex::Reactive.verbose_errors (on in dev/test " \
74
+ "by default) or data-reactive-verbose on <html>"
75
+ end
76
+
77
+ def read_state
78
+ raw = @page.evaluate_script(<<~JS)
79
+ (() => {
80
+ const html = document.documentElement
81
+ return {
82
+ requests: html.getAttribute(#{REQUESTS_ATTR.to_json}),
83
+ active: html.hasAttribute(#{ACTIVE_MARKER.to_json}),
84
+ verbose: html.hasAttribute("data-reactive-verbose") ||
85
+ !!document.querySelector('[data-reactive-verbose="true"]'),
86
+ }
87
+ })()
88
+ JS
89
+ raw = {} unless raw.is_a?(::Hash)
90
+ @verbose = raw["verbose"] == true
91
+ @totals = System.parse_reactive_requests(raw["requests"])
92
+ @active = raw["active"] == true
93
+ end
94
+
95
+ def selected = @kind ? @totals[@kind] : @totals.values.sum
96
+
97
+ def scope = @kind ? "kind: #{@kind.inspect}" : "any kind"
98
+
99
+ def noun = @expected == 1 ? "request" : "requests"
100
+
101
+ def totals_str = "{#{@totals.map { |k, v| "#{k}: #{v}" }.join(", ")}}"
102
+
103
+ # Same bounded, monotonic-clock poll as ReactiveValueMatcher. A `break
104
+ # false` inside the block ends the poll early (a terminal mismatch).
105
+ def poll_until
106
+ deadline = now + (@wait || ::Capybara.default_max_wait_time)
107
+ loop do
108
+ return true if yield
109
+ return false if now >= deadline
110
+
111
+ sleep 0.05
112
+ end
113
+ end
114
+
115
+ def now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end