phlex-reactive 0.13.4 → 0.14.1

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 +1079 -630
  3. data/README.md +612 -17
  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 +1582 -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 +86 -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 +3706 -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 +188 -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 +47 -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 +27 -7
  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
@@ -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).
@@ -36,10 +36,11 @@ module Phlex
36
36
  # Actor-only ops: a js broadcast rejects a broadcast that carries one.
37
37
  # Focus ops steal focus in every subscriber's tab (issue #96); submit
38
38
  # (issue #226) would force-submit every subscriber's form; paste_into
39
- # (issue #228) would read every subscriber's clipboard. They belong to
40
- # the actor's own reply (reply.js) or gesture (on_client / a reducer's
41
- # $ops), never a broadcast. Names mirror Phlex::Reactive::JS's verbs.
42
- BROADCAST_REFUSED_OPS = %w[focus focus_first submit paste_into persist_state persist_clear].freeze
39
+ # (issue #228) would read every subscriber's clipboard; check_group
40
+ # (issue #342) would tick or clear every subscriber's selection. They
41
+ # belong to the actor's own reply (reply.js) or gesture (on_client / a
42
+ # reducer's $ops), never a broadcast. Names mirror Phlex::Reactive::JS's verbs.
43
+ BROADCAST_REFUSED_OPS = %w[focus focus_first submit paste_into persist_state persist_clear check_group].freeze
43
44
 
44
45
  # The broadcast_to verb kwargs (issue #185) → their Turbo stream action.
45
46
  # SELF-TARGETING verbs derive the target from the component's #id (require a
@@ -75,7 +76,7 @@ module Phlex
75
76
  broadcast_remove_to_each: "broadcast_to(each: keys, remove: model)"
76
77
  }.freeze
77
78
 
78
- # Every class that includes Streamable, so the engine can flush their
79
+ # Every class that includes Streamable or subclasses one, so the engine can flush their
79
80
  # memoized view contexts on a Rails code reload (dev) in one pass. A
80
81
  # WeakMap used as a set (the class is the key) so a class reloaded by
81
82
  # Zeitwerk in dev is GC'd normally — a strong Array would pin every
@@ -148,7 +149,15 @@ module Phlex
148
149
  # different hosts — absolute URLs in broadcast-rendered components
149
150
  # keep the process defaults, so "host-relative URLs" stays the
150
151
  # 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) }
152
+ # Nor the actor's AWAKE dormant roots (issue #274): a subscriber may
153
+ # never have loaded the client, so its copy stays dormant (one
154
+ # fiber-local read off-request).
155
+ html =
156
+ if verb != :js
157
+ Phlex::Reactive::Dormant.asleep do
158
+ Phlex::Reactive.with_url_options(nil) { render_broadcast_html(component) }
159
+ end
160
+ end
152
161
  ops_json = verb == :js ? broadcast_js_ops_json(payload) : nil
153
162
  keys.each { dispatch_broadcast(verb, it, resolved_target, html, ops_json, morph, effect) }
154
163
  end
@@ -183,7 +192,8 @@ module Phlex
183
192
  raise ArgumentError,
184
193
  "broadcast_to(js:) refuses actor-only op(s) #{refused.join(", ")} — broadcasting focus " \
185
194
  "steals it in every subscriber's tab; broadcasting submit force-submits every " \
186
- "subscriber's form; broadcasting paste_into reads every subscriber's clipboard. " \
195
+ "subscriber's form; broadcasting paste_into reads every subscriber's clipboard; " \
196
+ "broadcasting check_group ticks or clears every subscriber's selection. " \
187
197
  "These are actor concerns (reply.js / on_client / $ops)."
188
198
  end
189
199
 
@@ -313,6 +323,16 @@ module Phlex
313
323
  end
314
324
 
315
325
  class_methods do
326
+ # A subclass inherits Streamable rather than including it, so `included`
327
+ # never fires for it (issue #302). Register it here so the inventory
328
+ # (rake tasks, MCP tools), the doctor and the reload reset see it too.
329
+ # Anonymous subclasses register as well; readers filter to
330
+ # constant-resolvable classes, and the WeakMap lets them be GC'd.
331
+ def inherited(subclass)
332
+ super
333
+ Phlex::Reactive::Streamable.register(subclass)
334
+ end
335
+
316
336
  # The keyword the positional model maps to in `initialize`. For a
317
337
  # record-backed component (Component#reactive_record), this is the SAME
318
338
  # 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
@@ -36,6 +36,30 @@ module Phlex
36
36
  # twin of wait_for_turbo watching the Turbo progress bar.
37
37
  ACTIVE_MARKER = "data-reactive-active"
38
38
 
39
+ # The <html> attribute the client keeps its per-kind request totals in
40
+ # (issue #279), as JSON ({"action":1,"defer":0}). Lockstep with
41
+ # reactive_controller.js REQUESTS_ATTR. Written only under the verbose
42
+ # gate (dev/test by default).
43
+ REQUESTS_ATTR = "data-reactive-requests"
44
+
45
+ # Parse the attribute value into { action:, defer: } Integers — zeros for
46
+ # an absent or garbled value. Shared by the helpers and
47
+ # ReactiveRequestsMatcher.
48
+ def self.parse_reactive_requests(raw)
49
+ parsed = raw.is_a?(::String) ? ::JSON.parse(raw) : {}
50
+ parsed = {} unless parsed.is_a?(::Hash)
51
+ { action: reactive_request_total(parsed["action"]), defer: reactive_request_total(parsed["defer"]) }
52
+ rescue ::JSON::ParserError
53
+ { action: 0, defer: 0 }
54
+ end
55
+
56
+ # One total as an Integer. Only a number or a numeric string counts; any
57
+ # other JSON value (array, object, boolean, null) is garbled, so zero.
58
+ def self.reactive_request_total(value)
59
+ value.is_a?(::Numeric) || value.is_a?(::String) ? value.to_i : 0
60
+ end
61
+ private_class_method :reactive_request_total
62
+
39
63
  # Block until the reactive layer is IDLE — every dispatch round trip and
40
64
  # deferred render has settled and the <html data-reactive-active> marker is
41
65
  # gone. The system-test twin of wait_for_turbo (which watches the Turbo
@@ -92,6 +116,42 @@ module Phlex
92
116
  def have_reactive_text(id, value, **)
93
117
  have_css("##{id}", text: value, **)
94
118
  end
119
+
120
+ # How many reactive requests the page has made since the last
121
+ # reset_reactive_requests! (or since the page loaded), per kind (issue
122
+ # #279):
123
+ #
124
+ # reactive_request_count # => { action: 1, defer: 0 }
125
+ #
126
+ # A snapshot, not a wait — barrier on wait_for_reactive (or use
127
+ # have_reactive_requests) before reading it after a gesture.
128
+ def reactive_request_count
129
+ System.parse_reactive_requests(
130
+ page.evaluate_script("document.documentElement.getAttribute(#{REQUESTS_ATTR.to_json})")
131
+ )
132
+ end
133
+
134
+ # Baseline the request totals to zero. The attribute IS the client's
135
+ # store, so writing zeros re-baselines it — including after a Turbo Drive
136
+ # visit, where the <html> element (and its totals) survives navigation.
137
+ def reset_reactive_requests!
138
+ zeros = { action: 0, defer: 0 }.to_json
139
+ page.execute_script("document.documentElement.setAttribute(#{REQUESTS_ATTR.to_json}, '#{zeros}')")
140
+ nil
141
+ end
142
+
143
+ # Assert (waiting) that exactly `count` reactive requests were made — of
144
+ # `kind` (:action or :defer), or of any kind — since the last reset, AND
145
+ # that nothing is still in flight. Fails at once if the count overshoots
146
+ # (totals only grow until a reset).
147
+ #
148
+ # reset_reactive_requests!
149
+ # find("#open-panel").click
150
+ # expect(page).to have_reactive_requests(1)
151
+ # expect(page).to have_reactive_requests(0, kind: :defer)
152
+ def have_reactive_requests(count, kind: nil, timeout: nil, wait: nil)
153
+ ReactiveRequestsMatcher.new(count, kind:, wait: timeout || wait)
154
+ end
95
155
  # rubocop:enable Naming/PredicatePrefix
96
156
 
97
157
  # A waiting matcher that asserts a field's live `.value` PROPERTY (issue
@@ -190,3 +250,5 @@ module Phlex
190
250
  end
191
251
  end
192
252
  end
253
+
254
+ require "phlex/reactive/test_helpers/system/reactive_requests_matcher"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.13.4"
5
+ VERSION = "0.14.1"
6
6
  end
7
7
  end
@@ -133,6 +133,12 @@ module Phlex
133
133
  # ActionPolicy::Unauthorized, ...).
134
134
  attr_accessor :authorization_errors
135
135
 
136
+ # Import maps `phlex_reactive:doctor` checks for the `phlex/reactive/early`
137
+ # pin (issue #307): a callable returning `{ name => Importmap::Map }`, for
138
+ # an app with more than one map (one per entry point). nil (the default)
139
+ # checks `Rails.application.importmap` only.
140
+ attr_accessor :importmaps
141
+
136
142
  # The path the action endpoint is mounted at. Default "/reactive/actions".
137
143
  # Set before boot if it collides with an app route. The client runtime
138
144
  # reads it from a <meta name="phlex-reactive-action-path"> tag if present,
@@ -532,6 +538,29 @@ module Phlex
532
538
  instance.view_context
533
539
  end
534
540
 
541
+ # --- Early triggers (issue #273) -----------------------------------
542
+
543
+ # How long (ms) a trigger captured by phlex/reactive/early before the
544
+ # controller connected stays replayable. Older entries are dropped on
545
+ # connect (warned under data-reactive-verbose): a click from long ago must
546
+ # not fire a request out of nowhere. The client reads it from a meta tag
547
+ # in the host layout (the engine can't inject into it):
548
+ # <meta name="phlex-reactive-early-ttl" content="<%= Phlex::Reactive.early_event_ttl_ms %>">
549
+ # Without the meta the client uses the same 10 s default. nil resets.
550
+ # Validated at assignment: the client reads a non-positive TTL as "use
551
+ # the default", so 0 can never mean "replay nothing" — fail here instead.
552
+ def early_event_ttl_ms=(value)
553
+ unless value.nil? || (value.is_a?(Integer) && value.positive?)
554
+ raise ArgumentError, "early_event_ttl_ms must be a positive Integer (milliseconds) or nil, got #{value.inspect}"
555
+ end
556
+
557
+ @early_event_ttl_ms = value
558
+ end
559
+
560
+ def early_event_ttl_ms
561
+ @early_event_ttl_ms ||= 10_000
562
+ end
563
+
535
564
  # --- Deferred reply segments (issue #165) --------------------------
536
565
 
537
566
  # Lifetime (seconds) of a defer token. It only needs to cover the
@@ -552,6 +581,44 @@ module Phlex
552
581
  @defer_path ||= "/reactive/defer"
553
582
  end
554
583
 
584
+ # The path prefix of the cacheable-fragment GET endpoint (issue #277):
585
+ # GET <fragment_path>/<signed id>. Default "/reactive/fragment"; set
586
+ # before boot if it collides. The shell renders the full URL, but the
587
+ # client only fetches one under the path it knows: when you change this,
588
+ # also render <meta name="phlex-reactive-fragment-path" content="…">.
589
+ attr_writer :fragment_path
590
+
591
+ def fragment_path
592
+ @fragment_path ||= "/reactive/fragment"
593
+ end
594
+
595
+ # The longest max-age (seconds) a `reactive_lazy cache:` component may
596
+ # ask the browser to keep its fragment for. A component declaring more
597
+ # is answered with this cap. Within max-age the browser reuses its copy
598
+ # WITHOUT asking the server — so this also bounds how long a revoked
599
+ # permission can keep showing a stale fragment. Default 1 hour; nil
600
+ # restores it; 0 makes every cached fragment revalidate on each use.
601
+ def fragment_cache_max_age_limit=(value)
602
+ seconds = value.is_a?(ActiveSupport::Duration) && value.to_i == value ? value.to_i : value
603
+ unless seconds.nil? || (seconds.is_a?(::Integer) && !seconds.negative?)
604
+ raise ArgumentError,
605
+ "Phlex::Reactive.fragment_cache_max_age_limit must be a whole, non-negative number of seconds " \
606
+ "(or nil for the default) — got #{value.inspect}"
607
+ end
608
+
609
+ @fragment_cache_max_age_limit = seconds
610
+ end
611
+
612
+ def fragment_cache_max_age_limit
613
+ @fragment_cache_max_age_limit ||= 3600
614
+ end
615
+
616
+ # The deterministic, no-expiry, purpose-scoped fragment id of an identity
617
+ # payload, and its verification (issue #277). See Phlex::Reactive::Fragment.
618
+ def sign_fragment(payload) = Fragment.sign(payload)
619
+
620
+ def verify_fragment(id) = Fragment.verify(id)
621
+
555
622
  # How deferred segments reach the actor: :auto (push iff capable, else
556
623
  # pull), :fetch (always pull), :stream (push; degrades to pull with a
557
624
  # warning when the capability is absent). Validated at assignment — a
@@ -1056,6 +1123,7 @@ module Phlex
1056
1123
  # The controller a correctly-mounted action path resolves to. Used by the
1057
1124
  # route guard below.
1058
1125
  ACTIONS_CONTROLLER = "phlex/reactive/actions"
1126
+ FRAGMENTS_CONTROLLER = "phlex/reactive/fragments"
1059
1127
 
1060
1128
  # True when a POST to `path` resolves to the gem's ActionsController. A host
1061
1129
  # catch-all route (match "*path", ...) appended above the engine's route
@@ -1076,6 +1144,20 @@ module Phlex
1076
1144
  false
1077
1145
  end
1078
1146
 
1147
+ # Does GET <fragment_path>/<id> resolve to the gem's FragmentsController
1148
+ # (issue #277)? A host GET catch-all shadows it otherwise, and every
1149
+ # `reactive_lazy cache:` fetch then gets the host's fallback page. The
1150
+ # doctor reports it, like the defer route.
1151
+ def fragment_route_ok?(path = fragment_path)
1152
+ return false unless defined?(::Rails) && ::Rails.application
1153
+
1154
+ ensure_routes_loaded
1155
+ recognized = ::Rails.application.routes.recognize_path("#{path}/probe", method: :get)
1156
+ recognized[:controller] == FRAGMENTS_CONTROLLER
1157
+ rescue ActionController::RoutingError, ActiveRecord::RecordNotFound
1158
+ false
1159
+ end
1160
+
1079
1161
  # Log a clear warning (once, at boot) when the action path doesn't resolve
1080
1162
  # to the gem controller — pointing at the catch-all shadow rather than
1081
1163
  # leaving an adopter to guess. Called from the engine's after_initialize.
@@ -1184,6 +1266,8 @@ loader.ignore("#{lib}/phlex/reactive/test_helpers/matchers.rb")
1184
1266
  # only when Capybara is present, so the loader must ignore it (otherwise an
1185
1267
  # eager-load in production would define browser helpers with no Capybara).
1186
1268
  loader.ignore("#{lib}/phlex/reactive/test_helpers/system.rb")
1269
+ # ...and its matcher subdirectory (issue #279), required by system.rb itself.
1270
+ loader.ignore("#{lib}/phlex/reactive/test_helpers/system")
1187
1271
  # The MCP diagnostic tool tree (issue #168) subclasses the OPTIONAL `mcp` gem's
1188
1272
  # constants (MCP::Tool) at class-definition time, so the whole mcp/ subdirectory
1189
1273
  # must stay out of the autoloader — Phlex::Reactive::MCP.load! requires it in
@@ -12,14 +12,18 @@ namespace :phlex_reactive do
12
12
  abort unless Phlex::Reactive::Doctor.run
13
13
  end
14
14
 
15
- desc "List every declared reactive action (component | action | params | file:line | auth); FORMAT=json for tooling"
15
+ desc "List every declared reactive action (component | action | params | file:line | auth); " \
16
+ "FORMAT=json for tooling, UNVERIFIED=1 for only the unverified rows"
16
17
  task actions: :environment do
17
18
  # eager_load! so every app component is in the Streamable registry the
18
19
  # Inspector reads. Plain text by default (no ANSI, like the doctor);
19
20
  # FORMAT=json emits a parseable array for tooling. Names/paths/schemas only.
21
+ # UNVERIFIED=1 keeps only actions with neither a detected authorization call
22
+ # nor a skip_verify_authorized — the review queue (issue #278).
20
23
  Rails.application.eager_load!
21
24
  format = ENV["FORMAT"].to_s.downcase == "json" ? :json : :text
22
- puts Phlex::Reactive::Inspector::Report.actions(Phlex::Reactive::Inspector.components, format:)
25
+ unverified_only = %w[1 true yes].include?(ENV["UNVERIFIED"].to_s.downcase)
26
+ puts Phlex::Reactive::Inspector::Report.actions(Phlex::Reactive::Inspector.components, format:, unverified_only:)
23
27
  end
24
28
 
25
29
  desc "Fuzzy-find a reactive component and print its actions with method-definition source — phlex_reactive:find[query]"